diff --git a/README.md b/README.md index dff0a901..e511bde4 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,50 @@ ProductBuilder.create() The `NotNull`/`NonNull` simple-name check is framework-agnostic; use the annotation type already used by your project. +#### Default Values + +Specify default values for fields that are applied when not explicitly set before `build()`: + +```java +import org.javahelpers.simple.builders.core.annotations.Default; + +@SimpleBuilder +public record Product( + String name, + double price, + @Default("GENERAL") String category, + @Default("true") boolean active +) {} + +// category defaults to "GENERAL", active defaults to true +Product product = ProductBuilder.create() + .name("Laptop") + .price(1500.0) + .build(); +// product.category() == "GENERAL" +// product.active() == true + +// Explicit values override defaults +Product custom = ProductBuilder.create() + .name("Widget") + .price(9.99) + .category("ACCESSORIES") + .active(false) + .build(); +// custom.category() == "ACCESSORIES" +``` + +The `@Default` annotation works on both **constructor parameters** (records) and **fields** (classes with setters). The `value()` is a string expression interpreted based on the field type: + +- **String** — wrapped in double quotes automatically (e.g. `@Default("GENERAL")` generates `"GENERAL"`) +- **char** — wrapped in single quotes (e.g. `@Default("A")` generates `'A'`) +- **numeric/boolean primitives** — used as-is (e.g. `@Default("0.0")` generates `0.0`) +- **complex types** — used as a raw Java expression (e.g. `@Default("List.of()")` generates `List.of()`) + +**Framework-agnostic detection:** The processor also detects annotations named `Default` or `DefaultValue` from any package (e.g. Jakarta REST `@DefaultValue`) if they have a `String value()` member. + +**Interaction with non-null checks:** A field with a `@Default` is never considered "required" — even if annotated with `@NotNull`, no validation error is raised when the field is unset. + #### Conditional Builder Logic Apply builder modifications conditionally using the `conditional()` method: @@ -409,6 +453,7 @@ Examples demonstrating special annotations and nested object relationships: - **Sponsor DTO**: [`SponsorDto.java`](example/src/main/java/org/javahelpers/simple/builders/example/SponsorDto.java) and [`SponsorDtoBuilder.java`](example/generated-example-builder/org/javahelpers/simple/builders/example/SponsorDtoBuilder.java) - Simple DTO used as nested object in other examples - **Mannschaft DTO**: [`MannschaftDto.java`](example/src/main/java/org/javahelpers/simple/builders/example/MannschaftDto.java) and [`MannschaftDtoBuilder.java`](example/generated-example-builder/org/javahelpers/simple/builders/example/MannschaftDtoBuilder.java) - Demonstrates `@IgnoreInBuilder` annotation to exclude specific setter methods from the generated builder, plus Set collections with nested objects +- **Default Values**: [`ProductWithDefaults.java`](example/src/main/java/org/javahelpers/simple/builders/example/ProductWithDefaults.java) (record) and [`OrderWithDefaults.java`](example/src/main/java/org/javahelpers/simple/builders/example/OrderWithDefaults.java) (class) - Demonstrate `@Default` annotation for unset builder fields These examples serve as both documentation and integration tests for the annotation processor. diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/Default.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/Default.java new file mode 100644 index 00000000..d874189b --- /dev/null +++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/Default.java @@ -0,0 +1,104 @@ +/* + * MIT License + * + * Copyright (c) 2026 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.javahelpers.simple.builders.core.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Specifies a default value for a builder field that is applied when the field is not explicitly + * set before calling {@code build()}. + * + *

The {@link #value()} is a string expression that is interpreted based on the field type: + * + *

+ * + *

Can be placed on constructor parameters or fields. When a field has a default value, it is no + * longer considered "required" even if annotated with {@code @NotNull} or {@code @NonNull}. + * + *

Example with a record: + * + *

{@code
+ * @SimpleBuilder
+ * public record Product(String name, double price,
+ *     @Default("GENERAL") String category) {}
+ *
+ * // category defaults to "GENERAL" if not set
+ * Product p = ProductBuilder.create()
+ *     .name("Widget")
+ *     .price(9.99)
+ *     .build();
+ * // p.category() == "GENERAL"
+ * }
+ * + *

Example with a class: + * + *

{@code
+ * @SimpleBuilder
+ * public class Order {
+ *   private String id;
+ *   @Default("PENDING") private String status;
+ *
+ *   public String getId() { return id; }
+ *   public void setId(String id) { this.id = id; }
+ *   public String getStatus() { return status; }
+ *   public void setStatus(String status) { this.status = status; }
+ * }
+ *
+ * // status defaults to "PENDING" if not set
+ * Order o = OrderBuilder.create()
+ *     .id("ORD-001")
+ *     .build();
+ * // o.getStatus() == "PENDING"
+ * }
+ * + *

Framework-agnostic detection: The builder processor also detects annotations named + * {@code Default} or {@code DefaultValue} from any package (e.g. Jakarta REST {@code + * jakarta.ws.rs.DefaultValue}) if they have a {@code String value()} member. + */ +@Target({ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.CLASS) +public @interface Default { + + /** + * The default value as a string expression, interpreted based on the field type. + * + *

For String fields, the value is quoted automatically. For char fields, it is single-quoted. + * For numeric/boolean primitives and complex types, it is used as a raw Java expression. + * + * @return the default value expression + */ + String value(); +} diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/util/TrackedValue.java b/core/src/main/java/org/javahelpers/simple/builders/core/util/TrackedValue.java index dca9ba98..0aa6de25 100644 --- a/core/src/main/java/org/javahelpers/simple/builders/core/util/TrackedValue.java +++ b/core/src/main/java/org/javahelpers/simple/builders/core/util/TrackedValue.java @@ -56,16 +56,60 @@ public boolean isSet() { return isChanged || isInitial; } + /** + * Returns the value if set, otherwise the given default. + * + * @param defaultValue the default to return if unset + * @return the value if set, otherwise the default + */ + public T valueOr(T defaultValue) { + return isSet() ? value : defaultValue; + } + /** * Executes the provided {@link Consumer} only if this value has been set (initial value or - * changed). + * changed). Returns a {@link DefaultValueApplier} that can fluently provide a default via {@code + * .orElse(default)} if the value was unset. + * + *

Existing code that ignores the return value (e.g. {@code tracked.ifSet(consumer);}) + * continues to work unchanged. * * @param consumer action to perform with the current {@link #value()} + * @return a {@link DefaultValueApplier} for fluent default handling */ - public void ifSet(Consumer consumer) { + public DefaultValueApplier ifSet(Consumer consumer) { if (isSet()) { consumer.accept(value); } + return new DefaultValueApplier<>(isSet(), consumer); + } + + /** + * Intermediate result returned by {@link #ifSet(Consumer)} to support fluent default-value + * application via {@code .orElse(default)}. + * + *

When {@link #ifSet(Consumer)} is called on a set {@link TrackedValue}, the consumer is + * invoked immediately and {@code alreadyApplied} is {@code true}, making the subsequent {@link + * #orElse(Object)} call a no-op. When called on an unset value, the consumer is not invoked and + * {@code alreadyApplied} is {@code false}, so {@link #orElse(Object)} applies the default value + * to the same consumer. + * + * @param the value type + * @param alreadyApplied whether the consumer has already been invoked + * @param consumer the consumer to apply the default value to if not already applied + */ + public record DefaultValueApplier(boolean alreadyApplied, Consumer consumer) { + + /** + * Applies the given default value to the consumer if the original value was unset. + * + * @param defaultValue the default value to apply + */ + public void orElse(T defaultValue) { + if (!alreadyApplied) { + consumer.accept(defaultValue); + } + } } /** diff --git a/core/src/test/java/org/javahelpers/simple/builders/core/util/TrackedValueTest.java b/core/src/test/java/org/javahelpers/simple/builders/core/util/TrackedValueTest.java index 124f8b4f..0b7eccfb 100644 --- a/core/src/test/java/org/javahelpers/simple/builders/core/util/TrackedValueTest.java +++ b/core/src/test/java/org/javahelpers/simple/builders/core/util/TrackedValueTest.java @@ -207,4 +207,95 @@ void shouldWorkWithComplexTypes() { assertTrue(intTracked.isSet()); assertTrue(doubleTracked.isSet()); } + + @Test + void valueOr_returnsValueWhenSet() { + TrackedValue tracked = TrackedValue.changedValue("actual"); + assertEquals("actual", tracked.valueOr("default")); + } + + @Test + void valueOr_returnsValueWhenInitial() { + TrackedValue tracked = TrackedValue.initialValue("initial"); + assertEquals("initial", tracked.valueOr("default")); + } + + @Test + void valueOr_returnsDefaultWhenUnset() { + TrackedValue tracked = TrackedValue.unsetValue(); + assertEquals("default", tracked.valueOr("default")); + } + + @Test + void valueOr_returnsNullDefaultWhenUnset() { + TrackedValue tracked = TrackedValue.unsetValue(); + assertNull(tracked.valueOr(null)); + } + + @Test + void valueOr_worksWithPrimitives() { + TrackedValue tracked = TrackedValue.unsetValue(); + assertEquals(42, tracked.valueOr(42)); + } + + @Test + void ifSet_orElse_appliesValueWhenChanged() { + TrackedValue tracked = TrackedValue.changedValue("actual"); + AtomicReference received = new AtomicReference<>("unchanged"); + + tracked.ifSet(received::set).orElse("default"); + + assertEquals("actual", received.get()); + } + + @Test + void ifSet_orElse_appliesValueWhenInitial() { + TrackedValue tracked = TrackedValue.initialValue("initial"); + AtomicReference received = new AtomicReference<>("unchanged"); + + tracked.ifSet(received::set).orElse("default"); + + assertEquals("initial", received.get()); + } + + @Test + void ifSet_orElse_appliesDefaultWhenUnset() { + TrackedValue tracked = TrackedValue.unsetValue(); + AtomicReference received = new AtomicReference<>("unchanged"); + + tracked.ifSet(received::set).orElse("default"); + + assertEquals("default", received.get()); + } + + @Test + void ifSet_orElse_appliesNullDefaultWhenUnset() { + TrackedValue tracked = TrackedValue.unsetValue(); + AtomicReference received = new AtomicReference<>("unchanged"); + + tracked.ifSet(received::set).orElse(null); + + assertNull(received.get()); + } + + @Test + void ifSet_returnValueCanBeIgnored() { + TrackedValue tracked = TrackedValue.changedValue("actual"); + AtomicReference received = new AtomicReference<>("unchanged"); + + // Simulate existing generated code that ignores the return value + tracked.ifSet(received::set); + + assertEquals("actual", received.get()); + } + + @Test + void ifSet_orElse_appliesPrimitiveDefaultWhenUnset() { + TrackedValue tracked = TrackedValue.unsetValue(); + AtomicReference received = new AtomicReference<>(0); + + tracked.ifSet(received::set).orElse(42); + + assertEquals(42, received.get()); + } } diff --git a/example/generated-example-builder/org/javahelpers/simple/builders/example/OrderWithDefaultsBuilder.java b/example/generated-example-builder/org/javahelpers/simple/builders/example/OrderWithDefaultsBuilder.java new file mode 100644 index 00000000..697ad094 --- /dev/null +++ b/example/generated-example-builder/org/javahelpers/simple/builders/example/OrderWithDefaultsBuilder.java @@ -0,0 +1,418 @@ +package org.javahelpers.simple.builders.example; + +import static org.javahelpers.simple.builders.core.util.TrackedValue.changedValue; +import static org.javahelpers.simple.builders.core.util.TrackedValue.initialValue; +import static org.javahelpers.simple.builders.core.util.TrackedValue.unsetValue; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javax.annotation.processing.Generated; +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.javahelpers.simple.builders.core.annotations.BuilderImplementation; +import org.javahelpers.simple.builders.core.interfaces.IBuilderBase; +import org.javahelpers.simple.builders.core.util.BuilderToStringStyle; +import org.javahelpers.simple.builders.core.util.TrackedValue; + +/** + * Builder for {@code org.javahelpers.simple.builders.example.OrderWithDefaults}. + *

+ * This builder provides a fluent API for creating instances of + * org.javahelpers.simple.builders.example.OrderWithDefaults with method chaining and validation. Use the static + * {@code create()} method to obtain a new builder instance, configure the desired properties using the setter methods, + * and then call {@code build()} to create the final DTO. + * + *

Example:

+ * + *
{@code
+ * OrderWithDefaults result = OrderWithDefaultsBuilder.create()
+ *     .id("example value")
+ *     .id("Hello %s", "World")
+ *     .id(() -> "example value")
+ *     .id(sb -> sb.append("text"))
+ *     .priority(42)
+ *     .priority(() -> 42)
+ *     .status("example value")
+ *     .status("Hello %s", "World")
+ *     .status(() -> "example value")
+ *     .status(sb -> sb.append("text"))
+ *     .build();
+ * }
+ */ +@Generated("Generated by org.javahelpers.simple.builders.processor.BuilderProcessor") +@BuilderImplementation(forClass = OrderWithDefaults.class) +public class OrderWithDefaultsBuilder implements IBuilderBase { + + /** + * Tracked value for id: id. + */ + private TrackedValue id = unsetValue(); + /** + * Tracked value for priority: priority. + */ + private TrackedValue priority = unsetValue(); + /** + * Tracked value for status: status. + */ + private TrackedValue status = unsetValue(); + + /** + * Empty constructor of builder for {@code org.javahelpers.simple.builders.example.OrderWithDefaults}. + */ + public OrderWithDefaultsBuilder() { + } + + /** + * Initialisation of builder for {@code org.javahelpers.simple.builders.example.OrderWithDefaults} by a instance. + * + * @param instance object instance for initialisiation + */ + public OrderWithDefaultsBuilder(OrderWithDefaults instance) { + this.id = initialValue(instance.getId()); + this.priority = initialValue(instance.getPriority()); + this.status = initialValue(instance.getStatus()); + } + + /** + * Creating a new builder for {@code org.javahelpers.simple.builders.example.OrderWithDefaults}. + * + *

Example:

+ * + *
{@code
+   * OrderWithDefaultsBuilder builder = OrderWithDefaultsBuilder.create();
+   * }
+ * + * @return builder for {@code org.javahelpers.simple.builders.example.OrderWithDefaults} + */ + public static OrderWithDefaultsBuilder create() { + return new OrderWithDefaultsBuilder(); + } + + /** + * Sets the value for id. + *

+ * Generated from setter {@link OrderWithDefaults#setId(String) setId(String id)} + * + *

Example:

+ * + *
{@code
+   * builder.id("example value");
+   * }
+ * + * @param id id + * @return current instance of builder + */ + public OrderWithDefaultsBuilder id(String id) { + this.id = changedValue(id); + return this; + } + + /** + * Sets the value for id by executing the provided consumer. + *

+ * Generated from setter {@link OrderWithDefaults#setId(String) setId(String id)} + * + *

Example:

+ * + *
{@code
+   * builder.id(sb -> sb.append("text"));
+   * }
+ * + * @param idStringBuilderConsumer consumer providing an instance of id + * @return current instance of builder + */ + public OrderWithDefaultsBuilder id(Consumer idStringBuilderConsumer) { + StringBuilder builder = new StringBuilder(); + idStringBuilderConsumer.accept(builder); + this.id = changedValue(builder.toString()); + return this; + } + + /** + * Sets the value for id by invoking the provided supplier. + *

+ * Generated from setter {@link OrderWithDefaults#setId(String) setId(String id)} + * + *

Example:

+ * + *
{@code
+   * builder.id(() -> "example value");
+   * }
+ * + * @param idSupplier supplier for id + * @return current instance of builder + */ + public OrderWithDefaultsBuilder id(Supplier idSupplier) { + this.id = changedValue(idSupplier.get()); + return this; + } + + /** + * Sets the String value for id by using String.format(format, args). See + * {@link String#format(String, Object...)} for details. + *

+ * Generated from setter {@link OrderWithDefaults#setId(String) setId(String id)} + * + *

Example:

+ * + *
{@code
+   * builder.id("Hello %s", "World");
+   * }
+ * + * @param format A format string + * @param args Arguments referenced by the format specifiers in the format string. + * @return current instance of builder + */ + public OrderWithDefaultsBuilder id(String format, Object... args) { + this.id = changedValue(String.format(format, args)); + return this; + } + + /** + * Sets the value for priority. + *

+ * Generated from setter {@link OrderWithDefaults#setPriority(int) setPriority(int priority)} + * + *

Example:

+ * + *
{@code
+   * builder.priority(42);
+   * }
+ * + * @param priority priority + * @return current instance of builder + */ + public OrderWithDefaultsBuilder priority(int priority) { + this.priority = changedValue(priority); + return this; + } + + /** + * Sets the value for priority by invoking the provided supplier. + *

+ * Generated from setter {@link OrderWithDefaults#setPriority(int) setPriority(int priority)} + * + *

Example:

+ * + *
{@code
+   * builder.priority(() -> 42);
+   * }
+ * + * @param prioritySupplier supplier for priority + * @return current instance of builder + */ + public OrderWithDefaultsBuilder priority(Supplier prioritySupplier) { + this.priority = changedValue(prioritySupplier.get()); + return this; + } + + /** + * Sets the value for status. + *

+ * Generated from setter {@link OrderWithDefaults#setStatus(String) setStatus(String status)} + * + *

Example:

+ * + *
{@code
+   * builder.status("example value");
+   * }
+ * + * @param status status + * @return current instance of builder + */ + public OrderWithDefaultsBuilder status(String status) { + this.status = changedValue(status); + return this; + } + + /** + * Sets the value for status by executing the provided consumer. + *

+ * Generated from setter {@link OrderWithDefaults#setStatus(String) setStatus(String status)} + * + *

Example:

+ * + *
{@code
+   * builder.status(sb -> sb.append("text"));
+   * }
+ * + * @param statusStringBuilderConsumer consumer providing an instance of status + * @return current instance of builder + */ + public OrderWithDefaultsBuilder status(Consumer statusStringBuilderConsumer) { + StringBuilder builder = new StringBuilder(); + statusStringBuilderConsumer.accept(builder); + this.status = changedValue(builder.toString()); + return this; + } + + /** + * Sets the value for status by invoking the provided supplier. + *

+ * Generated from setter {@link OrderWithDefaults#setStatus(String) setStatus(String status)} + * + *

Example:

+ * + *
{@code
+   * builder.status(() -> "example value");
+   * }
+ * + * @param statusSupplier supplier for status + * @return current instance of builder + */ + public OrderWithDefaultsBuilder status(Supplier statusSupplier) { + this.status = changedValue(statusSupplier.get()); + return this; + } + + /** + * Sets the String value for status by using String.format(format, args). See + * {@link String#format(String, Object...)} for details. + *

+ * Generated from setter {@link OrderWithDefaults#setStatus(String) setStatus(String status)} + * + *

Example:

+ * + *
{@code
+   * builder.status("Hello %s", "World");
+   * }
+ * + * @param format A format string + * @param args Arguments referenced by the format specifiers in the format string. + * @return current instance of builder + */ + public OrderWithDefaultsBuilder status(String format, Object... args) { + this.status = changedValue(String.format(format, args)); + return this; + } + + /** + * Validates that the id field is not null or empty. + *

+ * Generated from setter {@link OrderWithDefaults#setId(String) setId(String id)} + * + * @return this builder instance for chaining + * @throws IllegalArgumentException if id is null or empty + */ + OrderWithDefaultsBuilder validateId() { + if (!id.isSet() || id.value().trim().isEmpty()) { + throw new IllegalArgumentException("Id cannot be null or empty"); + } + return this; + } + + /** + * Validates that the status field is not null or empty. + *

+ * Generated from setter {@link OrderWithDefaults#setStatus(String) setStatus(String status)} + * + * @return this builder instance for chaining + * @throws IllegalArgumentException if status is null or empty + */ + OrderWithDefaultsBuilder validateStatus() { + if (!status.isSet() || status.value().trim().isEmpty()) { + throw new IllegalArgumentException("Status cannot be null or empty"); + } + return this; + } + + /** + * Conditionally applies builder modifications if the condition is true. + * + * @param condition the condition to evaluate + * @param yesCondition the consumer to apply if condition is true + * @return this builder instance + */ + public OrderWithDefaultsBuilder conditional(BooleanSupplier condition, + Consumer yesCondition) { + return conditional(condition, yesCondition, null); + } + + /** + * Conditionally applies builder modifications based on a condition evaluation. + * + * @param condition the condition to evaluate + * @param trueCase the consumer to apply if condition is true + * @param falseCase the consumer to apply if condition is false (can be null) + * @return this builder instance + */ + public OrderWithDefaultsBuilder conditional(BooleanSupplier condition, Consumer trueCase, + Consumer falseCase) { + if (condition.getAsBoolean()) { + trueCase.accept(this); + } else if (falseCase != null) { + falseCase.accept(this); + } + return this; + } + + /** + * Builds the configured DTO instance. + * + *

Example:

+ * + *
{@code
+   * OrderWithDefaults result = builder.build();
+   * }
+ */ + @Override + public OrderWithDefaults build() { + OrderWithDefaults result = new OrderWithDefaults(); + this.id.ifSet(result::setId); + this.priority.ifSet(result::setPriority).orElse(2); + this.status.ifSet(result::setStatus).orElse("PENDING"); + return result; + } + + /** + * Returns a string representation of this builder, including only fields that have been set. + * + * @return string representation of the builder + */ + @Override + public String toString() { + return new ToStringBuilder(this, BuilderToStringStyle.INSTANCE).append("id", this.id) + .append("priority", this.priority) + .append("status", this.status) + .toString(); + } + + /** + * Interface that can be implemented by the DTO to provide fluent modification methods. + */ + public interface With { + /** + * Initializes a builder from an instance of this class, using methods of this builder to change values and returns + * the new built object. + * + * @param b the consumer to apply modifications + * @return the modified instance + */ + default OrderWithDefaults with(Consumer b) { + OrderWithDefaultsBuilder builder; + try { + builder = new OrderWithDefaultsBuilder(OrderWithDefaults.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException( + "The interface 'OrderWithDefaultsBuilder.With' should only be implemented by classes, which could be casted to 'OrderWithDefaults'", + ex); + } + b.accept(builder); + return builder.build(); + } + + /** + * Creates a builder initialized from this instance. + * + * @return a builder initialized with this instance's values + */ + default OrderWithDefaultsBuilder with() { + try { + return new OrderWithDefaultsBuilder(OrderWithDefaults.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException( + "The interface 'OrderWithDefaultsBuilder.With' should only be implemented by classes, which could be casted to 'OrderWithDefaults'", + ex); + } + } + } +} \ No newline at end of file diff --git a/example/generated-example-builder/org/javahelpers/simple/builders/example/ProductWithDefaultsBuilder.java b/example/generated-example-builder/org/javahelpers/simple/builders/example/ProductWithDefaultsBuilder.java new file mode 100644 index 00000000..2e2f8bf0 --- /dev/null +++ b/example/generated-example-builder/org/javahelpers/simple/builders/example/ProductWithDefaultsBuilder.java @@ -0,0 +1,498 @@ +package org.javahelpers.simple.builders.example; + +import static org.javahelpers.simple.builders.core.util.TrackedValue.changedValue; +import static org.javahelpers.simple.builders.core.util.TrackedValue.initialValue; +import static org.javahelpers.simple.builders.core.util.TrackedValue.unsetValue; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javax.annotation.processing.Generated; +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.javahelpers.simple.builders.core.annotations.BuilderImplementation; +import org.javahelpers.simple.builders.core.interfaces.IBuilderBase; +import org.javahelpers.simple.builders.core.util.BuilderToStringStyle; +import org.javahelpers.simple.builders.core.util.TrackedValue; + +/** + * Builder for {@code org.javahelpers.simple.builders.example.ProductWithDefaults}. + *

+ * This builder provides a fluent API for creating instances of + * org.javahelpers.simple.builders.example.ProductWithDefaults with method chaining and validation. Use the static + * {@code create()} method to obtain a new builder instance, configure the desired properties using the setter methods, + * and then call {@code build()} to create the final DTO. + * + *

Example:

+ * + *
{@code
+ * ProductWithDefaults result = ProductWithDefaultsBuilder.create()
+ *     .name("example value")
+ *     .name("Hello %s", "World")
+ *     .name(() -> "example value")
+ *     .name(sb -> sb.append("text"))
+ *     .price(3.14)
+ *     .price(() -> 3.14)
+ *     .category("example value")
+ *     .category("Hello %s", "World")
+ *     .category(() -> "example value")
+ *     .category(sb -> sb.append("text"))
+ *     .active(true)
+ *     .active(() -> true)
+ *     .build();
+ * }
+ */ +@Generated("Generated by org.javahelpers.simple.builders.processor.BuilderProcessor") +@BuilderImplementation(forClass = ProductWithDefaults.class) +public class ProductWithDefaultsBuilder implements IBuilderBase { + + /** + * Tracked value for name: name. + */ + private TrackedValue name = unsetValue(); + /** + * Tracked value for price: price. + */ + private TrackedValue price = unsetValue(); + /** + * Tracked value for category: category. + */ + private TrackedValue category = unsetValue(); + /** + * Tracked value for active: active. + */ + private TrackedValue active = unsetValue(); + + /** + * Empty constructor of builder for {@code org.javahelpers.simple.builders.example.ProductWithDefaults}. + */ + public ProductWithDefaultsBuilder() { + } + + /** + * Initialisation of builder for {@code org.javahelpers.simple.builders.example.ProductWithDefaults} by a instance. + * + * @param instance object instance for initialisiation + */ + public ProductWithDefaultsBuilder(ProductWithDefaults instance) { + this.name = initialValue(instance.name()); + this.price = initialValue(instance.price()); + this.category = initialValue(instance.category()); + this.active = initialValue(instance.active()); + } + + /** + * Creating a new builder for {@code org.javahelpers.simple.builders.example.ProductWithDefaults}. + * + *

Example:

+ * + *
{@code
+   * ProductWithDefaultsBuilder builder = ProductWithDefaultsBuilder.create();
+   * }
+ * + * @return builder for {@code org.javahelpers.simple.builders.example.ProductWithDefaults} + */ + public static ProductWithDefaultsBuilder create() { + return new ProductWithDefaultsBuilder(); + } + + /** + * Sets the value for active. + *

+ * Generated from parameter in constructor + * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name, + * double price, String category, boolean active)} + * + *

Example:

+ * + *
{@code
+   * builder.active(true);
+   * }
+ * + * @param active active + * @return current instance of builder + */ + public ProductWithDefaultsBuilder active(boolean active) { + this.active = changedValue(active); + return this; + } + + /** + * Sets the value for active by invoking the provided supplier. + *

+ * Generated from parameter in constructor + * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name, + * double price, String category, boolean active)} + * + *

Example:

+ * + *
{@code
+   * builder.active(() -> true);
+   * }
+ * + * @param activeSupplier supplier for active + * @return current instance of builder + */ + public ProductWithDefaultsBuilder active(Supplier activeSupplier) { + this.active = changedValue(activeSupplier.get()); + return this; + } + + /** + * Sets the value for category. + *

+ * Generated from parameter in constructor + * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name, + * double price, String category, boolean active)} + * + *

Example:

+ * + *
{@code
+   * builder.category("example value");
+   * }
+ * + * @param category category + * @return current instance of builder + */ + public ProductWithDefaultsBuilder category(String category) { + this.category = changedValue(category); + return this; + } + + /** + * Sets the value for category by executing the provided consumer. + *

+ * Generated from parameter in constructor + * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name, + * double price, String category, boolean active)} + * + *

Example:

+ * + *
{@code
+   * builder.category(sb -> sb.append("text"));
+   * }
+ * + * @param categoryStringBuilderConsumer consumer providing an instance of category + * @return current instance of builder + */ + public ProductWithDefaultsBuilder category(Consumer categoryStringBuilderConsumer) { + StringBuilder builder = new StringBuilder(); + categoryStringBuilderConsumer.accept(builder); + this.category = changedValue(builder.toString()); + return this; + } + + /** + * Sets the value for category by invoking the provided supplier. + *

+ * Generated from parameter in constructor + * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name, + * double price, String category, boolean active)} + * + *

Example:

+ * + *
{@code
+   * builder.category(() -> "example value");
+   * }
+ * + * @param categorySupplier supplier for category + * @return current instance of builder + */ + public ProductWithDefaultsBuilder category(Supplier categorySupplier) { + this.category = changedValue(categorySupplier.get()); + return this; + } + + /** + * Sets the String value for category by using String.format(format, args). See + * {@link String#format(String, Object...)} for details. + *

+ * Generated from parameter in constructor + * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name, + * double price, String category, boolean active)} + * + *

Example:

+ * + *
{@code
+   * builder.category("Hello %s", "World");
+   * }
+ * + * @param format A format string + * @param args Arguments referenced by the format specifiers in the format string. + * @return current instance of builder + */ + public ProductWithDefaultsBuilder category(String format, Object... args) { + this.category = changedValue(String.format(format, args)); + return this; + } + + /** + * Sets the value for name. + *

+ * Generated from parameter in constructor + * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name, + * double price, String category, boolean active)} + * + *

Example:

+ * + *
{@code
+   * builder.name("example value");
+   * }
+ * + * @param name name + * @return current instance of builder + */ + public ProductWithDefaultsBuilder name(String name) { + this.name = changedValue(name); + return this; + } + + /** + * Sets the value for name by executing the provided consumer. + *

+ * Generated from parameter in constructor + * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name, + * double price, String category, boolean active)} + * + *

Example:

+ * + *
{@code
+   * builder.name(sb -> sb.append("text"));
+   * }
+ * + * @param nameStringBuilderConsumer consumer providing an instance of name + * @return current instance of builder + */ + public ProductWithDefaultsBuilder name(Consumer nameStringBuilderConsumer) { + StringBuilder builder = new StringBuilder(); + nameStringBuilderConsumer.accept(builder); + this.name = changedValue(builder.toString()); + return this; + } + + /** + * Sets the value for name by invoking the provided supplier. + *

+ * Generated from parameter in constructor + * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name, + * double price, String category, boolean active)} + * + *

Example:

+ * + *
{@code
+   * builder.name(() -> "example value");
+   * }
+ * + * @param nameSupplier supplier for name + * @return current instance of builder + */ + public ProductWithDefaultsBuilder name(Supplier nameSupplier) { + this.name = changedValue(nameSupplier.get()); + return this; + } + + /** + * Sets the String value for name by using String.format(format, args). See + * {@link String#format(String, Object...)} for details. + *

+ * Generated from parameter in constructor + * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name, + * double price, String category, boolean active)} + * + *

Example:

+ * + *
{@code
+   * builder.name("Hello %s", "World");
+   * }
+ * + * @param format A format string + * @param args Arguments referenced by the format specifiers in the format string. + * @return current instance of builder + */ + public ProductWithDefaultsBuilder name(String format, Object... args) { + this.name = changedValue(String.format(format, args)); + return this; + } + + /** + * Sets the value for price. + *

+ * Generated from parameter in constructor + * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name, + * double price, String category, boolean active)} + * + *

Example:

+ * + *
{@code
+   * builder.price(3.14);
+   * }
+ * + * @param price price + * @return current instance of builder + */ + public ProductWithDefaultsBuilder price(double price) { + this.price = changedValue(price); + return this; + } + + /** + * Sets the value for price by invoking the provided supplier. + *

+ * Generated from parameter in constructor + * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name, + * double price, String category, boolean active)} + * + *

Example:

+ * + *
{@code
+   * builder.price(() -> 3.14);
+   * }
+ * + * @param priceSupplier supplier for price + * @return current instance of builder + */ + public ProductWithDefaultsBuilder price(Supplier priceSupplier) { + this.price = changedValue(priceSupplier.get()); + return this; + } + + /** + * Validates that the category field is not null or empty. + *

+ * Generated from parameter in constructor + * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name, + * double price, String category, boolean active)} + * + * @return this builder instance for chaining + * @throws IllegalArgumentException if category is null or empty + */ + ProductWithDefaultsBuilder validateCategory() { + if (!category.isSet() || category.value().trim().isEmpty()) { + throw new IllegalArgumentException("Category cannot be null or empty"); + } + return this; + } + + /** + * Validates that the name field is not null or empty. + *

+ * Generated from parameter in constructor + * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name, + * double price, String category, boolean active)} + * + * @return this builder instance for chaining + * @throws IllegalArgumentException if name is null or empty + */ + ProductWithDefaultsBuilder validateName() { + if (!name.isSet() || name.value().trim().isEmpty()) { + throw new IllegalArgumentException("Name cannot be null or empty"); + } + return this; + } + + /** + * Conditionally applies builder modifications if the condition is true. + * + * @param condition the condition to evaluate + * @param yesCondition the consumer to apply if condition is true + * @return this builder instance + */ + public ProductWithDefaultsBuilder conditional(BooleanSupplier condition, + Consumer yesCondition) { + return conditional(condition, yesCondition, null); + } + + /** + * Conditionally applies builder modifications based on a condition evaluation. + * + * @param condition the condition to evaluate + * @param trueCase the consumer to apply if condition is true + * @param falseCase the consumer to apply if condition is false (can be null) + * @return this builder instance + */ + public ProductWithDefaultsBuilder conditional(BooleanSupplier condition, + Consumer trueCase, Consumer falseCase) { + if (condition.getAsBoolean()) { + trueCase.accept(this); + } else if (falseCase != null) { + falseCase.accept(this); + } + return this; + } + + /** + * Builds the configured DTO instance. + * + *

Example:

+ * + *
{@code
+   * ProductWithDefaults result = builder.build();
+   * }
+ */ + @Override + public ProductWithDefaults build() { + if (!this.price.isSet()) { + throw new IllegalStateException("Required field 'price' must be set before calling build()"); + } + if (this.price.value() == null) { + throw new IllegalStateException("Field 'price' is marked as non-null but null value was provided"); + } + ProductWithDefaults result = new ProductWithDefaults(this.name.value(), + this.price.value(), + this.category.valueOr("GENERAL"), + this.active.valueOr(true)); + return result; + } + + /** + * Returns a string representation of this builder, including only fields that have been set. + * + * @return string representation of the builder + */ + @Override + public String toString() { + return new ToStringBuilder(this, BuilderToStringStyle.INSTANCE).append("name", this.name) + .append("price", this.price) + .append("category", this.category) + .append("active", this.active) + .toString(); + } + + /** + * Interface that can be implemented by the DTO to provide fluent modification methods. + */ + public interface With { + /** + * Initializes a builder from an instance of this class, using methods of this builder to change values and returns + * the new built object. + * + * @param b the consumer to apply modifications + * @return the modified instance + */ + default ProductWithDefaults with(Consumer b) { + ProductWithDefaultsBuilder builder; + try { + builder = new ProductWithDefaultsBuilder(ProductWithDefaults.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException( + "The interface 'ProductWithDefaultsBuilder.With' should only be implemented by classes, which could be casted to 'ProductWithDefaults'", + ex); + } + b.accept(builder); + return builder.build(); + } + + /** + * Creates a builder initialized from this instance. + * + * @return a builder initialized with this instance's values + */ + default ProductWithDefaultsBuilder with() { + try { + return new ProductWithDefaultsBuilder(ProductWithDefaults.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException( + "The interface 'ProductWithDefaultsBuilder.With' should only be implemented by classes, which could be casted to 'ProductWithDefaults'", + ex); + } + } + } +} \ No newline at end of file diff --git a/example/src/main/java/org/javahelpers/simple/builders/example/OrderWithDefaults.java b/example/src/main/java/org/javahelpers/simple/builders/example/OrderWithDefaults.java new file mode 100644 index 00000000..dc654e13 --- /dev/null +++ b/example/src/main/java/org/javahelpers/simple/builders/example/OrderWithDefaults.java @@ -0,0 +1,87 @@ +/* + * MIT License + * + * Copyright (c) 2026 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.javahelpers.simple.builders.example; + +import org.javahelpers.simple.builders.core.annotations.Default; +import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + +/** + * Example showing default values for setter-based fields in a class using {@code @Default}. + * + *

When a field annotated with {@code @Default} is not explicitly set on the builder, the + * declared default value is applied via the setter at {@code build()} time. + * + *

Example usage: + * + *

{@code
+ * // status uses default "PENDING"
+ * OrderWithDefaults order = OrderWithDefaultsBuilder.create()
+ *     .id("ORD-001")
+ *     .build();
+ * // order.getPriority() == 3
+ * // order.getStatus() == "PENDING"
+ *
+ * // explicit value overrides default
+ * OrderWithDefaults shipped = OrderWithDefaultsBuilder.create()
+ *     .id("ORD-002")
+ *     .status("SHIPPED")
+ *     .build();
+ * // shipped.getPriority() == 3
+ * // shipped.getStatus() == "SHIPPED"
+ * }
+ */ +@SimpleBuilder +public class OrderWithDefaults { + + private String id; + @Default("PENDING") + private String status; + @Default("2") + private int priority; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public int getPriority() { + return priority; + } + + public void setPriority(int priority) { + this.priority = priority; + } +} diff --git a/example/src/main/java/org/javahelpers/simple/builders/example/ProductWithDefaults.java b/example/src/main/java/org/javahelpers/simple/builders/example/ProductWithDefaults.java new file mode 100644 index 00000000..b58a91e0 --- /dev/null +++ b/example/src/main/java/org/javahelpers/simple/builders/example/ProductWithDefaults.java @@ -0,0 +1,64 @@ +/* + * MIT License + * + * Copyright (c) 2026 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.javahelpers.simple.builders.example; + +import org.javahelpers.simple.builders.core.annotations.Default; +import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + +/** + * Example showing default values for unset builder fields using {@code @Default}. + * + *

When a field annotated with {@code @Default} is not explicitly set on the builder, the + * declared default value is used at {@code build()} time. + * + *

Example usage: + * + *

{@code
+ * // category and active use defaults
+ * ProductWithDefaults product = ProductWithDefaultsBuilder.create()
+ *     .name("Laptop")
+ *     .price(1500.0)
+ *     .build();
+ * // product.category() == "GENERAL"
+ * // product.active() == true
+ *
+ * // explicit values override defaults
+ * ProductWithDefaults custom = ProductWithDefaultsBuilder.create()
+ *     .name("Widget")
+ *     .price(9.99)
+ *     .category("ACCESSORIES")
+ *     .active(false)
+ *     .build();
+ * // custom.category() == "ACCESSORIES"
+ * // custom.active() == false
+ * }
+ */ +@SimpleBuilder +public record ProductWithDefaults( + String name, + double price, + @Default("GENERAL") String category, + @Default("true") boolean active +) {} 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 82904bf8..3d4e1289 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 @@ -28,6 +28,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.Element; import javax.lang.model.element.ElementKind; @@ -37,6 +38,7 @@ import javax.lang.model.type.DeclaredType; import org.javahelpers.simple.builders.processor.model.annotation.AnnotationDto; import org.javahelpers.simple.builders.processor.model.type.TypeName; +import org.javahelpers.simple.builders.processor.model.type.TypeNamePrimitive; import org.javahelpers.simple.builders.processor.processing.ProcessingContext; /** Extractor for field annotations, converting them from Java model elements to DTOs. */ @@ -260,4 +262,84 @@ public static boolean hasNonNullConstraint(VariableElement param) { private static boolean isNonNullAnnotation(String simpleName) { return "NotNull".equals(simpleName) || "NonNull".equals(simpleName); } + + /** + * Extracts the {@code value()} member from any annotation on the given element whose simple name + * matches one of the provided names, regardless of package. + * + *

For example, if {@code annotationNames} contains {@code "Default"} and {@code + * "DefaultValue"}, this will detect annotations from any package (e.g. {@code + * org.javahelpers.simple.builders.core.annotations.Default}, {@code jakarta.ws.rs.DefaultValue}) + * as long as they have a {@code String value()} member. + * + * @param element the element to check for annotations + * @param annotationNames the set of annotation simple names to look for + * @return an {@link Optional} containing the raw value string, or empty if no matching annotation + * with a {@code value()} member is present + */ + public static Optional extractAnnotationValue( + Element element, Set annotationNames) { + return element.getAnnotationMirrors().stream() + .filter(mirror -> isAnnotationWithName(mirror, annotationNames)) + .map(FieldAnnotationExtractor::getValueMember) + .filter(Optional::isPresent) + .map(Optional::get) + .findFirst(); + } + + /** + * Checks if the given annotation mirror's simple name matches one of the provided names. + * + * @param mirror the annotation mirror to check + * @param annotationNames the set of annotation simple names to match against + * @return {@code true} if the annotation's simple name is in the provided set + */ + private static boolean isAnnotationWithName( + AnnotationMirror mirror, Set annotationNames) { + if (!(mirror.getAnnotationType().asElement() instanceof TypeElement type)) { + return false; + } + return annotationNames.contains(type.getSimpleName().toString()); + } + + /** + * Extracts the {@code value()} member from an annotation mirror as a string. + * + * @param mirror the annotation mirror to extract from + * @return an {@link Optional} containing the value string, or empty if no {@code value()} member + * is present + */ + private static Optional getValueMember(AnnotationMirror mirror) { + return mirror.getElementValues().entrySet().stream() + .filter(e -> "value".equals(e.getKey().getSimpleName().toString())) + .map(e -> e.getValue().getValue().toString()) + .findFirst(); + } + + /** + * Formats a raw string default value as a Java expression based on the field type. + * + *

Interpretation rules: + * + *

    + *
  • {@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} + *
  • complex types (List, custom objects) — used as a raw Java expression, e.g. {@code + * List.of()} + *
+ * + * @param rawValue the raw string from the annotation's {@code value()} member + * @param fieldType the {@link TypeName} of the target field + * @return a formatted Java expression string suitable for code generation + */ + public static String formatDefaultExpression(String rawValue, TypeName fieldType) { + if (fieldType.equals(TypeName.of(String.class))) { + return "\"%s\"".formatted(rawValue); + } + if (fieldType.equals(TypeNamePrimitive.CHAR)) { + return "'%s'".formatted(rawValue); + } + return rawValue; + } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/builder/CoreMethodsEnhancer.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/builder/CoreMethodsEnhancer.java index 8ad0f497..e848e9fb 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/builder/CoreMethodsEnhancer.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/builder/CoreMethodsEnhancer.java @@ -133,9 +133,9 @@ protected BuilderMethodDto createBuildMethod(BuilderDefinitionDto builderDto) { // Create method implementation with validation and setter application StringBuilder code = new StringBuilder(); - // Add validation for non-nullable constructor fields + // Add validation for required constructor fields (non-nullable AND no default) for (var field : builderDto.getConstructorFieldsForBuilder()) { - if (field.isNonNullable()) { + if (field.isRequired()) { code.append("if (!this.") .append(field.getFieldNameInBuilder()) .append(".isSet()) {\n") @@ -153,12 +153,12 @@ protected BuilderMethodDto createBuildMethod(BuilderDefinitionDto builderDto) { } } - // Add validation for non-nullable setter fields + // Add validation for required setter fields (non-nullable AND no default) // Note: Primitives are stored as boxed types in TrackedValue, etc. // They can be null via Supplier methods: builder.pages(() -> null) - // So we need null checks for ALL non-nullable fields, including primitives + // So we need null checks for ALL required fields, including primitives for (var field : builderDto.getSetterFieldsForBuilder()) { - if (field.isNonNullable()) { + if (field.isRequired()) { code.append("if (this.") .append(field.getFieldNameInBuilder()) .append(".isSet() && this.") @@ -183,13 +183,17 @@ protected BuilderMethodDto createBuildMethod(BuilderDefinitionDto builderDto) { .append(");\n"); } - // Apply setter-based fields + // Apply setter-based fields, using default value if declared and field is unset for (var field : builderDto.getSetterFieldsForBuilder()) { code.append("this.") .append(field.getFieldNameInBuilder()) .append(".ifSet(result::") .append(field.getSetterName()) - .append(");\n"); + .append(")"); + field + .getDefaultValue() + .ifPresent(defaultValue -> code.append(".orElse(").append(defaultValue).append(")")); + code.append(";\n"); } code.append("return result;"); @@ -292,7 +296,14 @@ protected BuilderMethodDto createToStringMethod(BuilderDefinitionDto builderDto) /** Creates the constructor arguments string for the build() method. */ private String createConstructorArgsString(BuilderDefinitionDto builderDto) { return builderDto.getConstructorFieldsForBuilder().stream() - .map(field -> "this." + field.getFieldNameInBuilder() + ".value()") + .map( + field -> { + String fieldRef = "this." + field.getFieldNameInBuilder(); + return field + .getDefaultValue() + .map(defaultExpr -> fieldRef + ".valueOr(" + defaultExpr + ")") + .orElseGet(() -> fieldRef + ".value()"); + }) .reduce((a, b) -> a + ", " + b) .orElse(""); } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/util/MethodGeneratorUtil.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/util/MethodGeneratorUtil.java index fc79e59a..328ba5b2 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/util/MethodGeneratorUtil.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/util/MethodGeneratorUtil.java @@ -415,9 +415,9 @@ public static void addExampleChainFragment(BuilderMethodDto methodDto, TypeName * *

This helper method retrieves an example value for the given element type and formats it as a * fluent-chain fragment showing two values (e.g., {@code methodName(value1, value2)}) to make it - * clear that the method accepts multiple arguments. The fragment is stored on the BuilderMethodDto for - * later use by the ClassJavaDocEnhancer to synthesize both method-level and class-level Javadoc - * examples. + * clear that the method accepts multiple arguments. The fragment is stored on the + * BuilderMethodDto for later use by the ClassJavaDocEnhancer to synthesize both method-level and + * class-level Javadoc examples. * *

If no example value is available for the element type, the fragment is not added. * @@ -436,8 +436,8 @@ public static void addExampleChainFragmentVarArgs( *

This helper method retrieves an example value for the given field type and formats it as a * fluent-chain fragment with a supplier. For types with an empty constructor, a method reference * is used (e.g., {@code methodName(Type::new)}); otherwise a lambda is used (e.g., {@code - * methodName(() -> exampleValue)}). The fragment is stored on the BuilderMethodDto for later use by the - * ClassJavaDocEnhancer to synthesize both method-level and class-level Javadoc examples. + * methodName(() -> exampleValue)}). The fragment is stored on the BuilderMethodDto for later use + * by the ClassJavaDocEnhancer to synthesize both method-level and class-level Javadoc examples. * *

If no example value is available for the field type, the fragment is not added. * diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/FieldDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/FieldDto.java index bd83f4f5..463956d3 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/FieldDto.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/FieldDto.java @@ -84,6 +84,13 @@ public class FieldDto { */ private final List parameterAnnotations = new ArrayList<>(); + /** + * Default value expression for this field, or {@code null} if no default is declared. The + * expression is pre-formatted as a Java code snippet (e.g. {@code "GENERAL"} for strings, {@code + * 0.0} for primitives, {@code List.of()} for complex types). + */ + private String defaultValue; + /** * Gets the original field name from the DTO. This name is used for generating method names, * parameter names, and setter method names (e.g., "userName" becomes "setUserName"). @@ -314,4 +321,36 @@ public boolean hasAnnotation(String annotationFqn) { annotation.getAnnotationType() != null && annotationFqn.equals(annotation.getAnnotationType().getFullQualifiedName())); } + + /** + * Gets the default value expression for this field, if one was declared via {@code @Default} or a + * compatible annotation. + * + * @return an {@link Optional} containing the formatted Java expression, or empty if no default + */ + public Optional getDefaultValue() { + return Optional.ofNullable(defaultValue); + } + + /** + * Sets the default value expression for this field. + * + * @param defaultValue the formatted Java expression (e.g. {@code "GENERAL"}, {@code 0.0}, {@code + * List.of()}), or {@code null} to clear + */ + public void setDefaultValue(String defaultValue) { + this.defaultValue = defaultValue; + } + + /** + * Whether this field is required to be set at build time. + * + *

A field is required if it is marked as non-nullable AND has no default value. A field with a + * default value is never required, even if annotated with {@code @NotNull}. + * + * @return {@code true} if the field must be explicitly set before {@code build()} + */ + public boolean isRequired() { + return nonNullable && defaultValue == null; + } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java index 377edc4b..a722e76c 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java @@ -63,6 +63,9 @@ /** Class for creating a specific BuilderDefinitionDto for an annotated DTO class. */ public class BuilderDefinitionCreator { + /** Annotation simple names recognized as default-value annotations, regardless of package. */ + private static final Set DEFAULT_ANNOTATION_NAMES = Set.of("Default", "DefaultValue"); + private BuilderDefinitionCreator() { // Private constructor to prevent instantiation } @@ -633,10 +636,20 @@ private static Optional createFieldFromSetter( builderType, context); - if (result.isPresent()) { - fieldNameRegistry.put(finalFieldName, result.get()); + if (result.isEmpty()) { + return result; + } + + FieldDto field = result.get(); + + // If no default was found on the setter parameter, check the field element itself + // (annotations like @Default may be placed on the field rather than the setter param) + if (field.getDefaultValue().isEmpty()) { + tryApplyDefaultFromField(field, dtoTypeElement, fieldName); } + fieldNameRegistry.put(finalFieldName, field); + return result; } @@ -798,6 +811,13 @@ private static Optional createFieldDto( field.setNonNullable(true); } + // Extract default value from @Default or @DefaultValue annotation (if present) + FieldAnnotationExtractor.extractAnnotationValue(param, DEFAULT_ANNOTATION_NAMES) + .ifPresent( + rawDefault -> + field.setDefaultValue( + FieldAnnotationExtractor.formatDefaultExpression(rawDefault, fieldType))); + // Builder and constructor information is now set when TypeName is created in JavaLangMapper // Use GeneratorRegistry to generate all methods for this field @@ -807,4 +827,45 @@ private static Optional createFieldDto( return Optional.of(field); } + + /** + * Attempts to extract and apply a default value from the field declaration itself, if the field + * carries a recognized default annotation (e.g. {@code @Default}). Used as a fallback when no + * default was found on the setter parameter. + * + * @param field the field DTO to update with a default value if one is found + * @param dtoTypeElement the enclosing class element to search for the field + * @param fieldName the simple field name to look for + */ + private static void tryApplyDefaultFromField( + FieldDto field, TypeElement dtoTypeElement, String fieldName) { + Optional fieldElement = findFieldElement(dtoTypeElement, fieldName); + if (fieldElement.isEmpty()) { + return; + } + Optional rawDefault = + FieldAnnotationExtractor.extractAnnotationValue( + fieldElement.get(), DEFAULT_ANNOTATION_NAMES); + if (rawDefault.isEmpty()) { + return; + } + field.setDefaultValue( + FieldAnnotationExtractor.formatDefaultExpression(rawDefault.get(), field.getFieldType())); + } + + /** + * Finds a field element by name in the given class element. + * + * @param classElement the class to search in + * @param fieldName the simple field name to look for + * @return an {@link Optional} containing the field element, or empty if not found + */ + private static Optional findFieldElement( + TypeElement classElement, String fieldName) { + return classElement.getEnclosedElements().stream() + .filter(e -> e.getKind() == ElementKind.FIELD) + .filter(e -> e.getSimpleName().contentEquals(fieldName)) + .map(VariableElement.class::cast) + .findFirst(); + } } 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 27b6fde2..4090dbee 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 @@ -3626,4 +3626,50 @@ public class OrderDto { "public class OrderDtoFactory implements IBuilderBase", "@BuilderImplementation(forClass = Order.class)"); } + + /** + * Verifies that a setter with field-specific generics (e.g. {@code public void setValue(T + * value)}) is skipped by {@code createFieldFromSetter} and does not produce a builder field, + * while normal setters in the same class are still processed. + */ + @Test + void shouldSkipSetterWithFieldSpecificGenerics() { + String className = "GenericSetterDto"; + String builderClassName = className + "Builder"; + + JavaFileObject sourceFile = + ProcessorTestUtils.forSource( + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class GenericSetterDto { + private String name; + private Object value; + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public Object getValue() { return value; } + public void setValue(T value) { this.value = value; } + } + """); + + Compilation compilation = compile(sourceFile); + assertThat(compilation).succeeded(); + String generatedCode = loadGeneratedSource(compilation, builderClassName); + + // The normal setter for "name" must produce a builder method + ProcessorAsserts.assertContaining( + generatedCode, "public GenericSetterDtoBuilder name(String name)"); + + // The generic setter for "value" must NOT produce a builder method + ProcessorAsserts.assertNotContaining(generatedCode, "public GenericSetterDtoBuilder value("); + + // Compilation should have a warning about the ignored generic setter + assertThat(compilation) + .hadWarningContaining("Field 'value' has field-specific generics, so it will be ignored"); + } } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/ComprehensiveFeatureIntegrationTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/ComprehensiveFeatureIntegrationTest.java index 6615be7f..eb7574d2 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/ComprehensiveFeatureIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/ComprehensiveFeatureIntegrationTest.java @@ -92,7 +92,10 @@ public AddressDto(String street, String city) { import java.util.Set; import java.util.Map; import java.util.Optional; - @org.javahelpers.simple.builders.core.annotations.SimpleBuilder + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + import org.javahelpers.simple.builders.core.annotations.Default; + + @SimpleBuilder public class PersonDto { private final String name; private final int age; @@ -104,7 +107,8 @@ public class PersonDto { private final List previousAddresses; private final LinkedList phoneNumbers; - public PersonDto(String name, int age, Optional email, + public PersonDto(String name, int age, + @Default("Optional.empty()") Optional email, List nicknames, Set tags, Map metadata, AddressDto address, List previousAddresses, @@ -1211,7 +1215,7 @@ public PersonDto build() { } PersonDto result = new PersonDto(this.name.value(), this.age.value(), - this.email.value(), + this.email.valueOr(Optional.empty()), this.nicknames.value(), this.tags.value(), this.metadata.value(), 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 new file mode 100644 index 00000000..2fa5c7d6 --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/DefaultValueTest.java @@ -0,0 +1,359 @@ +/* + * MIT License + * + * Copyright (c) 2026 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.javahelpers.simple.builders.processor; + +import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.assertGenerationSucceeded; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; + +import com.google.testing.compile.Compilation; +import java.util.stream.Stream; +import javax.tools.JavaFileObject; +import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts; +import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Tests for default value support via {@code @Default} and third-party {@code @DefaultValue} + * annotations. + */ +class DefaultValueTest { + + protected Compilation compile(JavaFileObject... sourceFiles) { + return ProcessorTestUtils.createCompiler().compile(sourceFiles); + } + + private static Stream constructorDefaultCases() { + return Stream.of( + Arguments.of( + "ProductRecord", + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.Default; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public record ProductRecord( + String name, + double price, + @Default("GENERAL") String category) {} + """, + """ + public ProductRecord build() { + if (!this.price.isSet()) { + throw new IllegalStateException("Required field 'price' must be set before calling build()"); + } + if (this.price.value() == null) { + throw new IllegalStateException("Field 'price' is marked as non-null but null value was provided"); + } + ProductRecord result = new ProductRecord(this.name.value(), this.price.value(), this.category.valueOr("GENERAL")); + return result; + } + """), + Arguments.of( + "MetricRecord", + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.Default; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public record MetricRecord( + String name, + @Default("0.0") double price, + @Default("0") int quantity) {} + """, + """ + public MetricRecord build() { + MetricRecord result = new MetricRecord(this.name.value(), this.price.valueOr(0.0), this.quantity.valueOr(0)); + return result; + } + """), + Arguments.of( + "CharRecord", + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.Default; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public record CharRecord( + String name, + @Default("X") char grade) {} + """, + """ + public CharRecord build() { + CharRecord result = new CharRecord(this.name.value(), this.grade.valueOr('X')); + return result; + } + """), + Arguments.of( + "PlainRecord", + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public record PlainRecord(String name, Integer age) {} + """, + """ + public PlainRecord build() { + PlainRecord result = new PlainRecord(this.name.value(), this.age.value()); + return result; + } + """)); + } + + @ParameterizedTest + @MethodSource("constructorDefaultCases") + void constructorDefaultCases_generateExpectedBuildMethod( + String recordName, String source, String expectedBuildMethod) { + String builderClassName = recordName + "Builder"; + Compilation compilation = compile(ProcessorTestUtils.forSource(source)); + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertGenerationSucceeded(compilation, builderClassName, generatedCode); + ProcessorAsserts.assertContaining(generatedCode, expectedBuildMethod); + } + + /** + * Verifies that a {@code @Default} annotation on a field in a setter-based class causes the + * generated {@code build()} method to use {@code ifSet(result::setStatus).orElse("PENDING")} + * instead of plain {@code ifSet(result::setStatus)}. + * + *

Also verifies the builder setter method for the defaulted field is still generated, so users + * can override the default with an explicit value. + */ + @Test + void defaultAppliedWhenUnset_setterField_class() { + String className = "OrderDto"; + String builderClassName = className + "Builder"; + + 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 OrderDto { + private String id; + @Default("PENDING") + private String status; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + } + """); + + Compilation compilation = compile(sourceFile); + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertGenerationSucceeded(compilation, builderClassName, generatedCode); + + // build() must use ifSet().orElse() with the quoted String default for the status field + ProcessorAsserts.assertContaining( + generatedCode, + """ + public OrderDto build() { + OrderDto result = new OrderDto(); + this.id.ifSet(result::setId); + this.status.ifSet(result::setStatus).orElse("PENDING"); + return result; + } + """); + // Setter method for the defaulted field must still be generated + ProcessorAsserts.assertContaining( + generatedCode, "public OrderDtoBuilder status(String status)"); + } + + /** + * 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 + * ensure defaults are not accidentally applied when not declared. + */ + @Test + void setterFieldWithoutDefault_usesIfSetOnly() { + String className = "OrderDto"; + String builderClassName = className + "Builder"; + + JavaFileObject sourceFile = + ProcessorTestUtils.forSource( + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class OrderDto { + private String id; + private String status; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + } + """); + + Compilation compilation = compile(sourceFile); + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertGenerationSucceeded(compilation, builderClassName, generatedCode); + + // Without default, should use plain ifSet (no .orElse) + ProcessorAsserts.assertContaining( + generatedCode, + """ + public OrderDto build() { + OrderDto result = new OrderDto(); + this.id.ifSet(result::setId); + this.status.ifSet(result::setStatus); + return result; + } + """); + } + + // === Default + non-null interaction === + + /** + * Verifies the interaction between {@code @NotNull} and {@code @Default}: + * + *

    + *
  • A field with both {@code @NotNull} and {@code @Default} is not required (the + * default makes it optional), so no required-field validation is generated. + *
  • A field with only {@code @NotNull} (no {@code @Default}) remains required, so + * required-field validation is still generated. + *
  • The defaulted field uses {@code valueOr()} in the constructor call. + *
+ */ + @Test + void defaultWithNonNull_skipsValidation() { + String recordName = "RequiredRecord"; + String builderClassName = recordName + "Builder"; + + JavaFileObject sourceFile = + ProcessorTestUtils.forSource( + """ + package test; + + import jakarta.validation.constraints.NotNull; + import org.javahelpers.simple.builders.core.annotations.Default; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public record RequiredRecord( + @NotNull @Default("UNKNOWN") String name, + @NotNull String required) {} + """); + + // Create mock for NotNull annotation + JavaFileObject notNullMock = + ProcessorTestUtils.createMockAnnotation( + "jakarta.validation.constraints", + "NotNull", + "ElementType.FIELD, ElementType.PARAMETER"); + + Compilation compilation = compile(notNullMock, sourceFile); + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertGenerationSucceeded(compilation, builderClassName, generatedCode); + + // name has @Default → not required → no required-field validation + // name uses valueOr with the default + // required has no @Default → still required → validation present + // required uses plain value() (no default) + ProcessorAsserts.assertContaining( + generatedCode, + """ + public RequiredRecord build() { + if (!this.required.isSet()) { + throw new IllegalStateException("Required field 'required' must be set before calling build()"); + } + if (this.required.value() == null) { + throw new IllegalStateException("Field 'required' is marked as non-null but null value was provided"); + } + RequiredRecord result = new RequiredRecord(this.name.valueOr("UNKNOWN"), this.required.value()); + return result; + } + """); + } + + // === Framework-agnostic detection === + + /** + * Verifies that the processor detects third-party annotations named {@code @DefaultValue} (e.g., + * Jakarta REST {@code jakarta.ws.rs.DefaultValue}) by simple name matching, not just our own + * {@code @Default}. The generated code should use {@code valueOr()} with the annotation's value. + */ + @Test + void detectsThirdPartyDefaultValueAnnotation() { + String recordName = "JakartaRecord"; + String builderClassName = recordName + "Builder"; + + // Create a mock @DefaultValue annotation (simulating Jakarta REST) + JavaFileObject defaultValueMock = + ProcessorTestUtils.createMockAnnotation( + "jakarta.ws.rs", + "DefaultValue", + "ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD", + "String value();"); + + JavaFileObject sourceFile = + ProcessorTestUtils.forSource( + """ + package test; + + import jakarta.ws.rs.DefaultValue; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public record JakartaRecord( + String name, + @DefaultValue("FALLBACK") String category) {} + """); + + Compilation compilation = compile(defaultValueMock, sourceFile); + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertGenerationSucceeded(compilation, builderClassName, generatedCode); + + // "category" field detects @DefaultValue → generates valueOr with quoted default + // "name" field is without @DefaultValue → must still use plain value() + ProcessorAsserts.assertContaining( + generatedCode, + """ + public JakartaRecord build() { + JakartaRecord result = new JakartaRecord(this.name.value(), this.category.valueOr("FALLBACK")); + return result; + } + """); + } +}