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 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
+ * 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.
+ *
+ *
+ * Generated from setter {@link OrderWithDefaults#setId(String) setId(String id)}
+ *
+ *
+ * Generated from setter {@link OrderWithDefaults#setId(String) setId(String id)}
+ *
+ *
+ * Generated from setter {@link OrderWithDefaults#setId(String) setId(String id)}
+ *
+ *
+ * Generated from setter {@link OrderWithDefaults#setId(String) setId(String id)}
+ *
+ *
+ * Generated from setter {@link OrderWithDefaults#setPriority(int) setPriority(int priority)}
+ *
+ *
+ * Generated from setter {@link OrderWithDefaults#setPriority(int) setPriority(int priority)}
+ *
+ *
+ * Generated from setter {@link OrderWithDefaults#setStatus(String) setStatus(String status)}
+ *
+ *
+ * Generated from setter {@link OrderWithDefaults#setStatus(String) setStatus(String status)}
+ *
+ *
+ * Generated from setter {@link OrderWithDefaults#setStatus(String) setStatus(String status)}
+ *
+ *
+ * Generated from setter {@link OrderWithDefaults#setStatus(String) setStatus(String status)}
+ *
+ *
+ * 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
+ * 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.
+ *
+ *
+ * Generated from parameter in constructor
+ * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name,
+ * double price, String category, boolean active)}
+ *
+ *
+ * Generated from parameter in constructor
+ * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name,
+ * double price, String category, boolean active)}
+ *
+ *
+ * Generated from parameter in constructor
+ * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name,
+ * double price, String category, boolean active)}
+ *
+ *
+ * Generated from parameter in constructor
+ * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name,
+ * double price, String category, boolean active)}
+ *
+ *
+ * Generated from parameter in constructor
+ * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name,
+ * double price, String category, boolean active)}
+ *
+ *
+ * Generated from parameter in constructor
+ * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name,
+ * double price, String category, boolean active)}
+ *
+ *
+ * Generated from parameter in constructor
+ * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name,
+ * double price, String category, boolean active)}
+ *
+ *
+ * Generated from parameter in constructor
+ * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name,
+ * double price, String category, boolean active)}
+ *
+ *
+ * Generated from parameter in constructor
+ * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name,
+ * double price, String category, boolean active)}
+ *
+ *
+ * Generated from parameter in constructor
+ * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name,
+ * double price, String category, boolean active)}
+ *
+ *
+ * Generated from parameter in constructor
+ * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name,
+ * double price, String category, boolean active)}
+ *
+ *
+ * Generated from parameter in constructor
+ * {@link ProductWithDefaults#ProductWithDefaults(String, double, String, boolean) ProductWithDefaults(String name,
+ * double price, String category, boolean active)}
+ *
+ *
+ * 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 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:
+ *
+ * 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:
+ *
+ * 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 Interpretation rules:
+ *
+ * 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 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 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}:
+ *
+ * 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 IBuilderBaseid: id.
+ */
+ private TrackedValuepriority: priority.
+ */
+ private TrackedValuestatus: status.
+ */
+ private TrackedValueExample:
+ *
+ * {@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.
+ * 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.
+ * Example:
+ *
+ * {@code
+ * builder.id(sb -> sb.append("text"));
+ * }
+ *
+ * @param idStringBuilderConsumer consumer providing an instance of id
+ * @return current instance of builder
+ */
+ public OrderWithDefaultsBuilder id(Consumerid by invoking the provided supplier.
+ * Example:
+ *
+ * {@code
+ * builder.id(() -> "example value");
+ * }
+ *
+ * @param idSupplier supplier for id
+ * @return current instance of builder
+ */
+ public OrderWithDefaultsBuilder id(Supplierid by using String.format(format, args). See
+ * {@link String#format(String, Object...)} for details.
+ * 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.
+ * 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.
+ * Example:
+ *
+ * {@code
+ * builder.priority(() -> 42);
+ * }
+ *
+ * @param prioritySupplier supplier for priority
+ * @return current instance of builder
+ */
+ public OrderWithDefaultsBuilder priority(Supplierstatus.
+ * 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.
+ * Example:
+ *
+ * {@code
+ * builder.status(sb -> sb.append("text"));
+ * }
+ *
+ * @param statusStringBuilderConsumer consumer providing an instance of status
+ * @return current instance of builder
+ */
+ public OrderWithDefaultsBuilder status(Consumerstatus by invoking the provided supplier.
+ * Example:
+ *
+ * {@code
+ * builder.status(() -> "example value");
+ * }
+ *
+ * @param statusSupplier supplier for status
+ * @return current instance of builder
+ */
+ public OrderWithDefaultsBuilder status(Supplierstatus by using String.format(format, args). See
+ * {@link String#format(String, Object...)} for details.
+ * 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.
+ * 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(ConsumerExample:
+ *
+ * {@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 IBuilderBasename: name.
+ */
+ private TrackedValueprice: price.
+ */
+ private TrackedValuecategory: category.
+ */
+ private TrackedValueactive: active.
+ */
+ private TrackedValueExample:
+ *
+ * {@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.
+ * 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.
+ * Example:
+ *
+ * {@code
+ * builder.active(() -> true);
+ * }
+ *
+ * @param activeSupplier supplier for active
+ * @return current instance of builder
+ */
+ public ProductWithDefaultsBuilder active(Suppliercategory.
+ * 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.
+ * Example:
+ *
+ * {@code
+ * builder.category(sb -> sb.append("text"));
+ * }
+ *
+ * @param categoryStringBuilderConsumer consumer providing an instance of category
+ * @return current instance of builder
+ */
+ public ProductWithDefaultsBuilder category(Consumercategory by invoking the provided supplier.
+ * Example:
+ *
+ * {@code
+ * builder.category(() -> "example value");
+ * }
+ *
+ * @param categorySupplier supplier for category
+ * @return current instance of builder
+ */
+ public ProductWithDefaultsBuilder category(Suppliercategory by using String.format(format, args). See
+ * {@link String#format(String, Object...)} for details.
+ * 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.
+ * 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.
+ * Example:
+ *
+ * {@code
+ * builder.name(sb -> sb.append("text"));
+ * }
+ *
+ * @param nameStringBuilderConsumer consumer providing an instance of name
+ * @return current instance of builder
+ */
+ public ProductWithDefaultsBuilder name(Consumername by invoking the provided supplier.
+ * Example:
+ *
+ * {@code
+ * builder.name(() -> "example value");
+ * }
+ *
+ * @param nameSupplier supplier for name
+ * @return current instance of builder
+ */
+ public ProductWithDefaultsBuilder name(Suppliername by using String.format(format, args). See
+ * {@link String#format(String, Object...)} for details.
+ * 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.
+ * 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.
+ * Example:
+ *
+ * {@code
+ * builder.price(() -> 3.14);
+ * }
+ *
+ * @param priceSupplier supplier for price
+ * @return current instance of builder
+ */
+ public ProductWithDefaultsBuilder price(SupplierExample:
+ *
+ * {@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{@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}.
+ *
+ * {@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.
+ *
+ *
+ *
+ *
+ * @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
+ *
+ */
+ @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;
+ }
+ """);
+ }
+}