diff --git a/README.md b/README.md index 21b72b7e..0c9558e7 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Simple Builders is a Java [annotation processor](https://docs.oracle.com/en/java - **Collections Support**: Built-in support for collections and maps - **Annotation Preservation**: Validation annotations are automatically copied to builder methods - **With Interface Pattern**: Type-safe object modifications using generated With interfaces +- **Jackson Support**: Supporting Jackson deserialization via `@JsonPOJOBuilder` and optional generation of `SimpleModule`s (one per package) (both need to be enabled) ## Requirements @@ -268,7 +269,7 @@ The `With` interface provides type-safe setter methods that mirror the builder's Simple Builders provides extensive configuration options to customize the generated builder code. You can control: -- Field setter generation (Supplier, Provider, Builder patterns) +- Field setter generation (Supplier, Consumer, Builder patterns) - Conditional logic helpers - Access modifiers for builders and methods - Collection helper methods diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java index 78c30449..ccc79376 100644 --- a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java +++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java @@ -568,6 +568,55 @@ */ OptionState generateWithInterface() default OptionState.UNSET; + /** + * Add Jackson annotations to the generated builder class.
+ * Adds {@code @JsonPOJOBuilder(withPrefix = "...")} to the builder class. The prefix matches + * the configured {@link #setterSuffix()}. + * + *

Example: + * + *

{@code
+     * @JsonDeserialize(builder = PersonDtoBuilder.class)
+     * public class PersonDto { ... }
+     *
+     * // Generated:
+     * @JsonPOJOBuilder(withPrefix = "")
+     * public class PersonDtoBuilder { ... }
+     * }
+ * + * Default: DISABLED
+ * Compiler option: -Asimplebuilder.usingJacksonDeserializerAnnotation + */ + OptionState usingJacksonDeserializerAnnotation() default OptionState.UNSET; + + /** + * Generate a Jackson SimpleModule containing registrations for all generated builders.
+ * This module allows Jackson to use the generated builders for deserialization without needing + * to annotate the DTO classes. + * + *

The generated module class will be named {@code SimpleBuildersJacksonModule} (by default). + * By default, a module is generated in each package containing processed DTOs. To group + * all registrations into a single module, use {@link #jacksonModulePackage()}. + * + *

Default: DISABLED
+ * Compiler option: -Asimplebuilder.generateJacksonModule + */ + OptionState generateJacksonModule() default OptionState.UNSET; + + /** + * Specifies the package name where the {@code SimpleBuildersJacksonModule} class will be + * generated.
+ * This is useful to avoid split-package issues or to group all module registrations into a + * single module. + * + *

If not specified, a separate module will be generated in each package containing + * processed DTOs. + * + *

Default: "" (empty - generate one module per package)
+ * Compiler option: -Asimplebuilder.jacksonModulePackage + */ + String jacksonModulePackage() default ""; + // === Naming === /** * Suffix to append to the DTO name to generate the builder class name.
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 103166a9..372f524c 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -81,17 +81,25 @@ Create reusable configuration presets with custom template annotations: ```java @SimpleBuilder.Template(options = @SimpleBuilder.Options( - generateFieldSupplier = false, - generateFieldProvider = false, - generateBuilderProvider = false, - generateConditionalHelper = false, - generateVarArgsHelpers = false, - usingArrayListBuilder = false, - usingArrayListBuilderWithElementBuilders = false, - usingHashSetBuilder = false, - usingHashSetBuilderWithElementBuilders = false, - usingHashMapBuilder = false, - generateWithInterface = false + generateFieldSupplier = OptionState.DISABLED, + generateFieldConsumer = OptionState.DISABLED, + generateBuilderConsumer = OptionState.DISABLED, + generateConditionalHelper = OptionState.DISABLED, + generateVarArgsHelpers = OptionState.DISABLED, + generateStringFormatHelpers = OptionState.DISABLED, + generateAddToCollectionHelpers = OptionState.DISABLED, + generateUnboxedOptional = OptionState.DISABLED, + copyTypeAnnotations = OptionState.DISABLED, + usingArrayListBuilder = OptionState.DISABLED, + usingArrayListBuilderWithElementBuilders = OptionState.DISABLED, + usingHashSetBuilder = OptionState.DISABLED, + usingHashSetBuilderWithElementBuilders = OptionState.DISABLED, + usingHashMapBuilder = OptionState.DISABLED, + generateWithInterface = OptionState.DISABLED, + usingGeneratedAnnotation = OptionState.DISABLED, + usingBuilderImplementationAnnotation = OptionState.DISABLED, + implementsBuilderBase = OptionState.DISABLED, + usingJacksonDeserializerAnnotation = OptionState.DISABLED )) @Retention(RetentionPolicy.CLASS) @Target(ElementType.TYPE) @@ -681,6 +689,93 @@ public class PersonDtoBuilder implements IBuilderBase { --- +#### `usingJacksonDeserializerAnnotation` + +**Default**: `DISABLED` | **Compiler Option**: `-Asimplebuilder.usingJacksonDeserializerAnnotation=ENABLED|DISABLED` + +Adds `@JsonPOJOBuilder` annotation to the builder class for Jackson deserialization support. + +**When ENABLED**: +```java +@JsonPOJOBuilder(withPrefix = "") +public class PersonDtoBuilder { + // ... +} +``` + +**When DISABLED**: No `@JsonPOJOBuilder` annotation. + +**Note**: This requires `com.fasterxml.jackson.core:jackson-databind` on the classpath during compilation. If missing, the annotation is skipped with a warning. + +--- + +#### `generateJacksonModule` + +**Default**: `DISABLED` | **Compiler Option**: `-Asimplebuilder.generateJacksonModule=ENABLED|DISABLED` + +Generates a Jackson `SimpleModule` (named `SimpleBuildersJacksonModule`) that registers all generated builders via MixIns. This allows deserialization without annotating your DTOs with `@JsonDeserialize`. + +**Requirement**: You MUST also enable [`usingJacksonDeserializerAnnotation`](#usingjacksondeserializerannotation). If `generateJacksonModule` is enabled but `usingJacksonDeserializerAnnotation` is disabled, the processor will issue a warning and skip module generation. + +**When ENABLED**: +1. The builder is generated as usual. +2. A `SimpleBuildersJacksonModule` class is generated. +3. The module registers a MixIn for the DTO that points to the Builder. + +**Package Name**: +By default, a `SimpleBuildersJacksonModule` is generated in **each package** that contains DTOs configured for Jackson module generation. This ensures deterministic behavior. +To specify a single fixed package name for all generated modules (grouping them into one), use the [`jacksonModulePackage`](#jacksonmodulepackage) option. + +**Generated Module Example**: +```java +package com.example.project.dto; // Generated in the same package as DTOs (by default) + +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +public class SimpleBuildersJacksonModule extends SimpleModule { + public SimpleBuildersJacksonModule() { + setMixInAnnotation(PersonDto.class, PersonDtoMixin.class); + } + + @JsonDeserialize(builder = PersonDtoBuilder.class) + private interface PersonDtoMixin {} +} +``` + +**Usage**: +```java +ObjectMapper mapper = new ObjectMapper(); + +// Register the module for your package +// Note: If you have DTOs in multiple packages and use the default strategy, +// you need to register the generated module for each package. +mapper.registerModule(new com.example.project.dto.SimpleBuildersJacksonModule()); + +PersonDto dto = mapper.readValue(json, PersonDto.class); +``` + +**Tip**: Use the [`jacksonModulePackage`](#jacksonmodulepackage) option to generate a single module for your entire project, making registration easier: +`mapper.registerModule(new com.example.project.config.SimpleBuildersJacksonModule());` + +**Note**: This requires `com.fasterxml.jackson.core:jackson-databind` on the classpath. + +--- + +#### `jacksonModulePackage` + +**Default**: `null` (uses package of each processed DTO) | **Compiler Option**: `-Asimplebuilder.jacksonModulePackage=com.your.package` + +Specifies the package name where the `SimpleBuildersJacksonModule` class will be generated. +This is highly recommended to ensure deterministic output location and avoid split-package issues. + +**Note**: If not specified, a separate `SimpleBuildersJacksonModule` will be generated in **each package** containing processed DTOs. + +**Example**: +`-Asimplebuilder.jacksonModulePackage=com.example.project.config` + +--- + ### Naming #### `builderSuffix` @@ -722,16 +817,27 @@ public class PersonDto { Generate only essential builder methods: ```java -@SimpleBuilder -@SimpleBuilder.Options( - generateFieldSupplier = OptionState.DISABLED, - generateFieldConsumer = OptionState.DISABLED, - generateBuilderConsumer = OptionState.DISABLED, - generateConditionalHelper = OptionState.DISABLED, - generateVarArgsHelpers = OptionState.DISABLED, - usingArrayListBuilder = OptionState.DISABLED, - usingHashMapBuilder = OptionState.DISABLED, - generateWithInterface = OptionState.DISABLED +@SimpleBuilder( + options = @SimpleBuilder.Options( + generateFieldSupplier = OptionState.DISABLED, + generateFieldConsumer = OptionState.DISABLED, + generateBuilderConsumer = OptionState.DISABLED, + generateConditionalHelper = OptionState.DISABLED, + generateVarArgsHelpers = OptionState.DISABLED, + generateStringFormatHelpers = OptionState.DISABLED, + generateAddToCollectionHelpers = OptionState.DISABLED, + generateUnboxedOptional = OptionState.DISABLED, + copyTypeAnnotations = OptionState.DISABLED, + usingArrayListBuilder = OptionState.DISABLED, + usingArrayListBuilderWithElementBuilders = OptionState.DISABLED, + usingHashSetBuilder = OptionState.DISABLED, + usingHashSetBuilderWithElementBuilders = OptionState.DISABLED, + usingHashMapBuilder = OptionState.DISABLED, + generateWithInterface = OptionState.DISABLED, + usingGeneratedAnnotation = OptionState.DISABLED, + usingBuilderImplementationAnnotation = OptionState.DISABLED, + implementsBuilderBase = OptionState.DISABLED, + usingJacksonDeserializerAnnotation = OptionState.DISABLED ) public class MinimalDto { private String name; @@ -770,11 +876,11 @@ Optimize for collection manipulation: ```java @SimpleBuilder @SimpleBuilder.Options( - generateVarArgsHelpers = true, - usingArrayListBuilder = true, - usingArrayListBuilderWithElementBuilders = true, - usingHashSetBuilder = true, - usingHashMapBuilder = true + generateVarArgsHelpers = OptionState.ENABLED, + usingArrayListBuilder = OptionState.ENABLED, + usingArrayListBuilderWithElementBuilders = OptionState.ENABLED, + usingHashSetBuilder = OptionState.ENABLED, + usingHashMapBuilder = OptionState.ENABLED ) public class TeamDto { private List memberNames; @@ -802,17 +908,25 @@ Create a reusable template for lightweight builders: ```java @SimpleBuilder.Template(options = @SimpleBuilder.Options( - generateFieldSupplier = false, - generateFieldProvider = false, - generateBuilderProvider = false, - generateConditionalHelper = false, - generateVarArgsHelpers = false, - usingArrayListBuilder = false, - usingArrayListBuilderWithElementBuilders = false, - usingHashSetBuilder = false, - usingHashSetBuilderWithElementBuilders = false, - usingHashMapBuilder = false, - generateWithInterface = false + generateFieldSupplier = OptionState.DISABLED, + generateFieldConsumer = OptionState.DISABLED, + generateBuilderConsumer = OptionState.DISABLED, + generateConditionalHelper = OptionState.DISABLED, + generateVarArgsHelpers = OptionState.DISABLED, + generateStringFormatHelpers = OptionState.DISABLED, + generateAddToCollectionHelpers = OptionState.DISABLED, + generateUnboxedOptional = OptionState.DISABLED, + copyTypeAnnotations = OptionState.DISABLED, + usingArrayListBuilder = OptionState.DISABLED, + usingArrayListBuilderWithElementBuilders = OptionState.DISABLED, + usingHashSetBuilder = OptionState.DISABLED, + usingHashSetBuilderWithElementBuilders = OptionState.DISABLED, + usingHashMapBuilder = OptionState.DISABLED, + generateWithInterface = OptionState.DISABLED, + usingGeneratedAnnotation = OptionState.DISABLED, + usingBuilderImplementationAnnotation = OptionState.DISABLED, + implementsBuilderBase = OptionState.DISABLED, + usingJacksonDeserializerAnnotation = OptionState.DISABLED )) @Retention(RetentionPolicy.CLASS) @Target(ElementType.TYPE) @@ -882,7 +996,7 @@ Configuration resolution follows these priority rules: // Global default: true @SimpleBuilder -@SimpleBuilder.Options(generateFieldSupplier = true) // Annotation wins! +@SimpleBuilder.Options(generateFieldSupplier = OptionState.ENABLED) // Annotation wins! public class Person { private String name; } @@ -1115,6 +1229,7 @@ methodAccess = AccessModifier.PRIVATE implementsBuilderBase = OptionState.ENABLED, usingGeneratedAnnotation = OptionState.ENABLED, usingBuilderImplementationAnnotation = OptionState.ENABLED, + usingJacksonDeserializerAnnotation = OptionState.ENABLED, // Naming builderSuffix = "Builder", diff --git a/example/pom.xml b/example/pom.xml index 50d19132..e58291dc 100644 --- a/example/pom.xml +++ b/example/pom.xml @@ -17,6 +17,7 @@ 2.25 3.18.0 5.11.4 + 2.18.2 3.13.0 3.1.1 @@ -49,6 +50,13 @@ test + + + com.fasterxml.jackson.core + jackson-databind + ${jackson-databind.version} + + diff --git a/example/src/main/java/org/javahelpers/simple/builders/example/ElementaryBuilder.java b/example/src/main/java/org/javahelpers/simple/builders/example/ElementaryBuilder.java index 24d1b4d2..88037b35 100644 --- a/example/src/main/java/org/javahelpers/simple/builders/example/ElementaryBuilder.java +++ b/example/src/main/java/org/javahelpers/simple/builders/example/ElementaryBuilder.java @@ -40,14 +40,19 @@ generateConditionalHelper = OptionState.DISABLED, generateVarArgsHelpers = OptionState.DISABLED, generateStringFormatHelpers = OptionState.DISABLED, + generateAddToCollectionHelpers = OptionState.DISABLED, generateUnboxedOptional = OptionState.DISABLED, + copyTypeAnnotations = OptionState.DISABLED, usingArrayListBuilder = OptionState.DISABLED, usingArrayListBuilderWithElementBuilders = OptionState.DISABLED, usingHashSetBuilder = OptionState.DISABLED, usingHashSetBuilderWithElementBuilders = OptionState.DISABLED, usingHashMapBuilder = OptionState.DISABLED, generateWithInterface = OptionState.DISABLED, - usingGeneratedAnnotation = OptionState.DISABLED)) + usingGeneratedAnnotation = OptionState.DISABLED, + usingBuilderImplementationAnnotation = OptionState.DISABLED, + implementsBuilderBase = OptionState.DISABLED, + usingJacksonDeserializerAnnotation = OptionState.DISABLED)) @Retention(RetentionPolicy.CLASS) @Target(ElementType.TYPE) public @interface ElementaryBuilder {} diff --git a/example/src/main/java/org/javahelpers/simple/builders/example/JacksonIntegrationDto.java b/example/src/main/java/org/javahelpers/simple/builders/example/JacksonIntegrationDto.java new file mode 100644 index 00000000..f0f7e190 --- /dev/null +++ b/example/src/main/java/org/javahelpers/simple/builders/example/JacksonIntegrationDto.java @@ -0,0 +1,28 @@ +package org.javahelpers.simple.builders.example; + +import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; +import org.javahelpers.simple.builders.core.enums.OptionState; + +@SimpleBuilder( + options = + @SimpleBuilder.Options( + generateJacksonModule = OptionState.ENABLED, + usingJacksonDeserializerAnnotation = OptionState.ENABLED)) +public class JacksonIntegrationDto { + private final String name; + private final int age; + + // Protected constructor - Jackson can't access this, but the builder can + protected JacksonIntegrationDto(String name, int age) { + this.name = name; + this.age = age; + } + + public String name() { + return name; + } + + public int age() { + return age; + } +} diff --git a/example/src/test/java/org/javahelpers/simple/builders/example/JacksonIntegrationTest.java b/example/src/test/java/org/javahelpers/simple/builders/example/JacksonIntegrationTest.java new file mode 100644 index 00000000..f5859f29 --- /dev/null +++ b/example/src/test/java/org/javahelpers/simple/builders/example/JacksonIntegrationTest.java @@ -0,0 +1,49 @@ +package org.javahelpers.simple.builders.example; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.InvalidDefinitionException; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JacksonIntegrationTest { + + @Test + void shouldFailWithoutJacksonModule() { + // Given + ObjectMapper mapper = new ObjectMapper(); + // NOTE: NOT registering the generated module + + String json = "{\"name\":\"Alice\",\"age\":30}"; + + // When & Then + InvalidDefinitionException exception = assertThrows( + InvalidDefinitionException.class, + () -> mapper.readValue(json, JacksonIntegrationDto.class) + ); + + // Should fail because Jackson can't access protected constructor + assertNotNull(exception.getMessage()); + assertTrue(exception.getMessage().contains("no Creators, like default constructor, exist")); + } + + @Test + void shouldSucceedWithJacksonModule() throws Exception { + // Given + ObjectMapper mapper = new ObjectMapper(); + // Register the generated module + mapper.registerModule(new SimpleBuildersJacksonModule()); + + String json = "{\"name\":\"Alice\",\"age\":30}"; + + // When + JacksonIntegrationDto dto = mapper.readValue(json, JacksonIntegrationDto.class); + + // Then + assertNotNull(dto); + assertEquals("Alice", dto.name()); + assertEquals(30, dto.age()); + } +} diff --git a/example/target/generated-sources/annotations/org/javahelpers/simple/builders/example/BookDtoBuilder.java b/example/target/generated-sources/annotations/org/javahelpers/simple/builders/example/BookDtoBuilder.java index 3b8dbb0b..704c96fd 100644 --- a/example/target/generated-sources/annotations/org/javahelpers/simple/builders/example/BookDtoBuilder.java +++ b/example/target/generated-sources/annotations/org/javahelpers/simple/builders/example/BookDtoBuilder.java @@ -7,25 +7,18 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; 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.BookDto}. */ -@BuilderImplementation( - forClass = BookDto.class -) -public class BookDtoBuilder implements IBuilderBase { +public class BookDtoBuilder { /** * Tracked value for title: the book title to set. */ @@ -178,42 +171,6 @@ public BookDtoBuilder(BookDto instance) { public BookDtoBuilder() { } - /** - * Adds a single element to genres. - * - * @param element the element to add - * @return current instance of builder - */ - public BookDtoBuilder add2Genres(String element) { - Set newCollection; - if (this.genres.isSet()) { - newCollection = new HashSet<>(this.genres.value()); - } else { - newCollection = new HashSet<>(); - } - newCollection.add(element); - this.genres = changedValue(newCollection); - return this; - } - - /** - * Adds a single element to tags. - * - * @param element the element to add - * @return current instance of builder - */ - public BookDtoBuilder add2Tags(String element) { - List newCollection; - if (this.tags.isSet()) { - newCollection = new ArrayList<>(this.tags.value()); - } else { - newCollection = new ArrayList<>(); - } - newCollection.add(element); - this.tags = changedValue(newCollection); - return this; - } - /** * Sets the value for author. * @@ -423,7 +380,6 @@ public BookDtoBuilder title(String title) { return this; } - @Override public BookDto build() { if (this.pages.isSet() && this.pages.value() == null) { throw new IllegalStateException("Field 'pages' is marked as non-null but null value was provided"); diff --git a/example/target/generated-sources/annotations/org/javahelpers/simple/builders/example/SimpleBuildersJacksonModule.java b/example/target/generated-sources/annotations/org/javahelpers/simple/builders/example/SimpleBuildersJacksonModule.java new file mode 100644 index 00000000..5766b3ae --- /dev/null +++ b/example/target/generated-sources/annotations/org/javahelpers/simple/builders/example/SimpleBuildersJacksonModule.java @@ -0,0 +1,16 @@ +package org.javahelpers.simple.builders.example; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.module.SimpleModule; + +public class SimpleBuildersJacksonModule extends SimpleModule { + public SimpleBuildersJacksonModule() { + setMixInAnnotation(JacksonIntegrationDto.class, JacksonIntegrationDtoMixin.class); + } + + @JsonDeserialize( + builder = JacksonIntegrationDtoBuilder.class + ) + private interface JacksonIntegrationDtoMixin { + } +} diff --git a/processor/pom.xml b/processor/pom.xml index 5a741268..02dc3a64 100644 --- a/processor/pom.xml +++ b/processor/pom.xml @@ -59,6 +59,7 @@ 4.5.0 6.0.1 0.23.0 + 2.18.2 17 @@ -142,6 +143,14 @@ ${google-compile-testing.version} test + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson-databind.version} + test + diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java index 98054a55..042db372 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java @@ -47,6 +47,7 @@ import org.javahelpers.simple.builders.processor.exceptions.BuilderException; import org.javahelpers.simple.builders.processor.util.BuilderConfigurationReader; import org.javahelpers.simple.builders.processor.util.CompilerArgumentsReader; +import org.javahelpers.simple.builders.processor.util.JacksonModuleGenerator; import org.javahelpers.simple.builders.processor.util.JavaCodeGenerator; import org.javahelpers.simple.builders.processor.util.ProcessingContext; import org.javahelpers.simple.builders.processor.util.ProcessingLogger; @@ -61,6 +62,7 @@ public class BuilderProcessor extends AbstractProcessor { private ProcessingContext context; private JavaCodeGenerator codeGenerator; + private JacksonModuleGenerator jacksonModuleGenerator; private boolean supportedJdk = true; @Override @@ -76,7 +78,10 @@ public synchronized void init(ProcessingEnvironment processingEnv) { new ProcessingContext( processingEnv.getElementUtils(), processingEnv.getTypeUtils(), logger, globalConfig); context.debug("Loaded global configuration from compiler arguments: %s", globalConfig); - this.codeGenerator = new JavaCodeGenerator(processingEnv.getFiler(), logger); + this.codeGenerator = + new JavaCodeGenerator(processingEnv.getFiler(), processingEnv.getElementUtils(), logger); + this.jacksonModuleGenerator = + new JacksonModuleGenerator(processingEnv.getElementUtils(), logger); SourceVersion current = processingEnv.getSourceVersion(); this.supportedJdk = isAtLeastJava17(current); @@ -94,6 +99,15 @@ public boolean process(Set annotations, RoundEnvironment return false; } + // Generate Jackson Module if processing is over and feature is enabled + if (roundEnv.processingOver()) { + var modules = jacksonModuleGenerator.getModuleDefinitions(); + for (var module : modules) { + codeGenerator.generateJacksonModule(module); + } + return false; + } + BuilderConfigurationReader reader = context.getConfigurationReader(); // Find all elements to process: @@ -163,6 +177,9 @@ private void process(Element annotatedElement, BuilderConfiguration config) context.initConfigurationForProcessingTarget(config); BuilderDefinitionDto builderDef = extractFromElement(annotatedElement, context); codeGenerator.generateBuilder(builderDef); + + // Collect info for Jackson Module if enabled + jacksonModuleGenerator.addEntry(builderDef, annotatedElement); } /** diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/BuilderConfiguration.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/BuilderConfiguration.java index 3f3f4b93..87def7aa 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/BuilderConfiguration.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/BuilderConfiguration.java @@ -27,6 +27,7 @@ import static org.javahelpers.simple.builders.core.enums.AccessModifier.*; import static org.javahelpers.simple.builders.core.enums.OptionState.*; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; @@ -86,6 +87,9 @@ public record BuilderConfiguration( OptionState usingBuilderImplementationAnnotation, OptionState implementsBuilderBase, OptionState generateWithInterface, + OptionState usingJacksonDeserializerAnnotation, + OptionState generateJacksonModule, + String jacksonModulePackage, String builderSuffix, String setterSuffix) { @@ -112,6 +116,9 @@ public record BuilderConfiguration( .usingBuilderImplementationAnnotation(ENABLED) .implementsBuilderBase(ENABLED) .generateWithInterface(ENABLED) + .usingJacksonDeserializerAnnotation(DISABLED) + .generateJacksonModule(DISABLED) + .jacksonModulePackage(null) .builderSuffix("Builder") .setterSuffix("") .build(); @@ -137,6 +144,14 @@ public boolean shouldGenerateWithInterface() { return generateWithInterface == ENABLED; } + public boolean shouldUseJacksonDeserializerAnnotation() { + return usingJacksonDeserializerAnnotation == ENABLED; + } + + public boolean shouldGenerateJacksonModule() { + return generateJacksonModule == ENABLED; + } + public boolean shouldGenerateVarArgsHelpers() { return generateVarArgsHelpers == ENABLED; } @@ -202,6 +217,10 @@ public AccessModifier getMethodAccess() { return methodAccess; } + public String getJacksonModulePackage() { + return jacksonModulePackage; + } + public String getBuilderSuffix() { return builderSuffix; } @@ -268,6 +287,12 @@ public BuilderConfiguration merge(BuilderConfiguration other) { mergeOptionState(other.implementsBuilderBase, this.implementsBuilderBase)) .generateWithInterface( mergeOptionState(other.generateWithInterface, this.generateWithInterface)) + .usingJacksonDeserializerAnnotation( + mergeOptionState( + other.usingJacksonDeserializerAnnotation, this.usingJacksonDeserializerAnnotation)) + .generateJacksonModule( + mergeOptionState(other.generateJacksonModule, this.generateJacksonModule)) + .jacksonModulePackage(mergeString(other.jacksonModulePackage, this.jacksonModulePackage)) .builderSuffix(mergeString(other.builderSuffix, this.builderSuffix)) .setterSuffix(mergeString(other.setterSuffix, this.setterSuffix)) .build(); @@ -361,6 +386,15 @@ public String toString() { if (generateWithInterface != UNSET) { builder.append("generateWithInterface", generateWithInterface); } + if (usingJacksonDeserializerAnnotation != UNSET) { + builder.append("usingJacksonDeserializerAnnotation", usingJacksonDeserializerAnnotation); + } + if (generateJacksonModule != UNSET) { + builder.append("generateJacksonModule", generateJacksonModule); + } + if (jacksonModulePackage != null) { + builder.append("jacksonModulePackage", jacksonModulePackage); + } if (builderSuffix != null && !builderSuffix.equals("Builder")) { builder.append("builderSuffix", builderSuffix); } @@ -406,6 +440,9 @@ public static class Builder { // === Integration === private OptionState implementsBuilderBase = OptionState.UNSET; private OptionState generateWithInterface = OptionState.UNSET; + private OptionState usingJacksonDeserializerAnnotation = OptionState.UNSET; + private OptionState generateJacksonModule = OptionState.UNSET; + private String jacksonModulePackage = null; // === Naming === private String builderSuffix = null; @@ -462,6 +499,31 @@ public Builder generateWithInterface(boolean value) { return this; } + public Builder usingJacksonDeserializerAnnotation(OptionState value) { + this.usingJacksonDeserializerAnnotation = value; + return this; + } + + public Builder usingJacksonDeserializerAnnotation(boolean value) { + this.usingJacksonDeserializerAnnotation = value ? ENABLED : DISABLED; + return this; + } + + public Builder generateJacksonModule(OptionState value) { + this.generateJacksonModule = value; + return this; + } + + public Builder generateJacksonModule(boolean value) { + this.generateJacksonModule = value ? ENABLED : DISABLED; + return this; + } + + public Builder jacksonModulePackage(String value) { + this.jacksonModulePackage = StringUtils.trimToNull(value); + return this; + } + public Builder generateVarArgsHelpers(OptionState value) { this.generateVarArgsHelpers = value; return this; @@ -655,6 +717,9 @@ public BuilderConfiguration build() { usingBuilderImplementationAnnotation, implementsBuilderBase, generateWithInterface, + usingJacksonDeserializerAnnotation, + generateJacksonModule, + jacksonModulePackage, builderSuffix, setterSuffix); } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/JacksonModuleDefinitionDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/JacksonModuleDefinitionDto.java new file mode 100644 index 00000000..b1acfdce --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/JacksonModuleDefinitionDto.java @@ -0,0 +1,58 @@ +/* + * MIT License + * + * Copyright (c) 2025 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.javahelpers.simple.builders.processor.dtos; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +/** + * Definition for generating a Jackson SimpleModule. Contains the target package and the entries to + * be registered in the module. + */ +public class JacksonModuleDefinitionDto { + private final String targetPackage; + private final Set entries = new HashSet<>(); + + public JacksonModuleDefinitionDto(String targetPackage) { + this.targetPackage = targetPackage; + } + + public String getTargetPackage() { + return targetPackage; + } + + public Set getEntries() { + return entries; + } + + public void addEntry(JacksonModuleEntryDto entry) { + this.entries.add(entry); + } + + public void addAllEntries(Collection entries) { + this.entries.addAll(entries); + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/JacksonModuleEntryDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/JacksonModuleEntryDto.java new file mode 100644 index 00000000..53a236a9 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/JacksonModuleEntryDto.java @@ -0,0 +1,33 @@ +/* + * MIT License + * + * Copyright (c) 2025 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.javahelpers.simple.builders.processor.dtos; + +/** + * Represents an entry for a Jackson module registration. Maps a DTO type to its builder type. + * + * @param dtoType the TypeName of the DTO + * @param builderType the TypeName of the builder + */ +public record JacksonModuleEntryDto(TypeName dtoType, TypeName builderType) {} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/enums/CompilerArgumentsEnum.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/enums/CompilerArgumentsEnum.java index 61d93b32..0957e6d5 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/enums/CompilerArgumentsEnum.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/enums/CompilerArgumentsEnum.java @@ -107,6 +107,15 @@ public enum CompilerArgumentsEnum { /** Option for With interface generation. */ GENERATE_WITH_INTERFACE("generateWithInterface"), + /** Option for Jackson support. */ + USING_JACKSON_DESERIALIZER_ANNOTATION("usingJacksonDeserializerAnnotation"), + + /** Option for Jackson Module generation. */ + GENERATE_JACKSON_MODULE("generateJacksonModule"), + + /** Option for Jackson Module package name. */ + JACKSON_MODULE_PACKAGE("jacksonModulePackage"), + // === Naming === /** Option for builder class name suffix. */ BUILDER_SUFFIX("builderSuffix"), diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderConfigurationReader.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderConfigurationReader.java index 70b113e9..1cfcc8d5 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderConfigurationReader.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderConfigurationReader.java @@ -192,6 +192,11 @@ private BuilderConfiguration parseOptionsFromMirror(AnnotationMirror optionsMirr builder.implementsBuilderBase(OptionState.valueOf(enumValue)); case "generateWithInterface" -> builder.generateWithInterface(OptionState.valueOf(enumValue)); + case "usingJacksonDeserializerAnnotation" -> + builder.usingJacksonDeserializerAnnotation(OptionState.valueOf(enumValue)); + case "generateJacksonModule" -> + builder.generateJacksonModule(OptionState.valueOf(enumValue)); + case "jacksonModulePackage" -> builder.jacksonModulePackage(value.toString()); case "builderSuffix" -> builder.builderSuffix(value.toString()); case "setterSuffix" -> builder.setterSuffix(value.toString()); default -> diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/CompilerArgumentsReader.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/CompilerArgumentsReader.java index 585883f4..51bd87aa 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/CompilerArgumentsReader.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/CompilerArgumentsReader.java @@ -169,6 +169,10 @@ public BuilderConfiguration readBuilderConfiguration() { readOptionState(CompilerArgumentsEnum.USING_BUILDER_IMPLEMENTATION_ANNOTATION)) .implementsBuilderBase(readOptionState(CompilerArgumentsEnum.IMPLEMENTS_BUILDER_BASE)) .generateWithInterface(readOptionState(CompilerArgumentsEnum.GENERATE_WITH_INTERFACE)) + .usingJacksonDeserializerAnnotation( + readOptionState(CompilerArgumentsEnum.USING_JACKSON_DESERIALIZER_ANNOTATION)) + .generateJacksonModule(readOptionState(CompilerArgumentsEnum.GENERATE_JACKSON_MODULE)) + .jacksonModulePackage(readValue(CompilerArgumentsEnum.JACKSON_MODULE_PACKAGE)) .builderSuffix(readValue(CompilerArgumentsEnum.BUILDER_SUFFIX)) .setterSuffix(readValue(CompilerArgumentsEnum.SETTER_SUFFIX)) .build(); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JacksonModuleGenerator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JacksonModuleGenerator.java new file mode 100644 index 00000000..fdf36740 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JacksonModuleGenerator.java @@ -0,0 +1,136 @@ +/* + * MIT License + * + * Copyright (c) 2025 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.javahelpers.simple.builders.processor.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import javax.lang.model.element.Element; +import javax.lang.model.util.Elements; +import org.apache.commons.collections4.SetValuedMap; +import org.apache.commons.collections4.multimap.HashSetValuedHashMap; +import org.javahelpers.simple.builders.processor.dtos.BuilderConfiguration; +import org.javahelpers.simple.builders.processor.dtos.BuilderDefinitionDto; +import org.javahelpers.simple.builders.processor.dtos.JacksonModuleDefinitionDto; +import org.javahelpers.simple.builders.processor.dtos.JacksonModuleEntryDto; + +/** + * Generates Jackson SimpleModules to register all generated builders. + * + *

The generated modules include MixIn interfaces to map DTOs to their Builders using + * {@code @JsonDeserialize(builder = ...)}. One module is generated per target package. + */ +public class JacksonModuleGenerator { + + private final Elements elementUtils; + private final ProcessingLogger logger; + private final SetValuedMap entriesByPackage = + new HashSetValuedHashMap<>(); + private final boolean jacksonAvailable; + + public JacksonModuleGenerator(Elements elementUtils, ProcessingLogger logger) { + this.elementUtils = elementUtils; + this.logger = logger; + this.jacksonAvailable = + this.elementUtils.getTypeElement("com.fasterxml.jackson.databind.module.SimpleModule") + != null; + } + + public void addEntry(BuilderDefinitionDto builderDef, Element sourceElement) { + BuilderConfiguration config = builderDef.getConfiguration(); + + if (!validateForModuleGeneration(config, sourceElement)) { + return; + } + + String targetPackage = getTargetPackage(config, builderDef); + + entriesByPackage.put( + targetPackage, + new JacksonModuleEntryDto( + builderDef.getBuildingTargetTypeName(), builderDef.getBuilderTypeName())); + } + + private String getTargetPackage(BuilderConfiguration config, BuilderDefinitionDto builderDef) { + String targetPackage = config.getJacksonModulePackage(); + if (targetPackage == null) { + targetPackage = builderDef.getBuildingTargetTypeName().getPackageName(); + } + return targetPackage; + } + + private boolean validateForModuleGeneration(BuilderConfiguration config, Element sourceElement) { + if (!config.shouldGenerateJacksonModule()) { + return false; + } + + if (!jacksonAvailable) { + logger.warning( + sourceElement, + "simple-builders: generateJacksonModule is enabled for %s, but Jackson dependencies (com.fasterxml.jackson.databind.module.SimpleModule) are not found on the classpath. Module generation skipped.", + sourceElement.getSimpleName()); + return false; + } + + if (!config.shouldUseJacksonDeserializerAnnotation()) { + logger.warning( + sourceElement, + "simple-builders: generateJacksonModule is enabled but usingJacksonDeserializerAnnotation is disabled. " + + "This is a misconfiguration. Jackson Module will NOT be generated for %s.", + sourceElement.getSimpleName()); + return false; + } + return true; + } + + /** + * Returns the definitions for the Jackson Modules to be generated. + * + *

Logs status and clears state after retrieval. + * + * @return list of JacksonModuleDefinitionDto + */ + public List getModuleDefinitions() { + List definitions = new ArrayList<>(); + if (!entriesByPackage.isEmpty()) { + logger.info( + "simple-builders: Processing OVER. JacksonModuleEnabled: true, Packages: %d", + entriesByPackage.keySet().size()); + + for (String packageName : entriesByPackage.keySet()) { + Set moduleEntries = entriesByPackage.get(packageName); + JacksonModuleDefinitionDto definition = new JacksonModuleDefinitionDto(packageName); + definition.addAllEntries(moduleEntries); + definitions.add(definition); + } + } + clear(); + return definitions; + } + + private void clear() { + entriesByPackage.clear(); + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java index e4c2b58a..db6856c5 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java @@ -44,6 +44,7 @@ import javax.annotation.processing.Filer; import javax.annotation.processing.Generated; import javax.lang.model.element.Modifier; +import javax.lang.model.util.Elements; import org.apache.commons.lang3.StringUtils; import org.javahelpers.simple.builders.core.annotations.BuilderImplementation; import org.javahelpers.simple.builders.core.interfaces.IBuilderBase; @@ -58,6 +59,7 @@ public class JavaCodeGenerator { private static final String THROW_EXCEPTION_FORMAT = "throw new $T($S)"; private final Filer filer; + private final Elements elementUtils; /** Logger for debug output during code generation. */ private final ProcessingLogger logger; @@ -67,10 +69,12 @@ public class JavaCodeGenerator { * * @param filer Util class for source code generation of type {@code * javax.annotation.processing.Filer} + * @param elementUtils Util class for operating on program elements * @param logger Logger for debug output */ - public JavaCodeGenerator(Filer filer, ProcessingLogger logger) { + public JavaCodeGenerator(Filer filer, Elements elementUtils, ProcessingLogger logger) { this.filer = filer; + this.elementUtils = elementUtils; this.logger = logger; } @@ -210,17 +214,28 @@ public void generateBuilder(BuilderDefinitionDto builderDef) throws BuilderExcep if (builderDef.getConfiguration().shouldUseBuilderImplementationAnnotation()) { classBuilder.addAnnotation(createAnnotationBuilderImplementation(dtoBaseClass)); } + if (builderDef.getConfiguration().shouldUseJacksonDeserializerAnnotation()) { + if (elementUtils.getTypeElement("com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder") + != null) { + classBuilder.addAnnotation( + createAnnotationJsonPOJOBuilder(builderDef.getConfiguration().getSetterSuffix())); + } else { + logger.warning( + "Jackson support enabled but 'com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder' not found on classpath. Annotation skipped."); + } + } logger.debug( "Writing builder class to file: %s.%s", builderDef.getBuilderTypeName().getPackageName(), builderDef.getBuilderTypeName().getClassName()); - writeClassToFile(builderDef.getBuilderTypeName().getPackageName(), classBuilder.build()); + writeBuilderClassToFile(builderDef.getBuilderTypeName().getPackageName(), classBuilder.build()); logger.debug( "Successfully generated builder: %s", builderDef.getBuilderTypeName().getClassName()); } - private void writeClassToFile(String packageName, TypeSpec typeSpec) throws BuilderException { + private void writeBuilderClassToFile(String packageName, TypeSpec typeSpec) + throws BuilderException { try { JavaFile.builder(packageName, typeSpec) .skipJavaLangImports(true) @@ -234,6 +249,15 @@ private void writeClassToFile(String packageName, TypeSpec typeSpec) throws Buil } } + private void writeSimpleClassToFile(String packageName, TypeSpec typeSpec) + throws BuilderException { + try { + JavaFile.builder(packageName, typeSpec).skipJavaLangImports(true).build().writeTo(filer); + } catch (IOException ex) { + throw new BuilderException(null, ex); + } + } + /** * Resolves method conflicts by keeping only the highest priority method for each unique * signature. This prevents compilation errors when methods from different fields have the same @@ -317,6 +341,14 @@ private AnnotationSpec createAnnotationBuilderImplementation(ClassName dtoClass) .build(); } + private AnnotationSpec createAnnotationJsonPOJOBuilder(String setterPrefix) { + ClassName jsonPojoBuilderClass = + ClassName.get("com.fasterxml.jackson.databind.annotation", "JsonPOJOBuilder"); + return AnnotationSpec.builder(jsonPojoBuilderClass) + .addMember("withPrefix", "$S", setterPrefix == null ? "" : setterPrefix) + .build(); + } + private MethodSpec createEmptyConstructor(ClassName dtoClass, Modifier accessModifier) { MethodSpec.Builder constructorBuilder = MethodSpec.constructorBuilder() @@ -705,4 +737,68 @@ private ParameterSpec createParameter(MethodParameterDto paramDto) { } return paramBuilder.build(); } + + /** + * Generates a Jackson SimpleModule based on the provided definition. + * + * @param moduleDef the definition of the Jackson module to generate + */ + public void generateJacksonModule(JacksonModuleDefinitionDto moduleDef) { + String packageName = moduleDef.getTargetPackage(); + String moduleClassName = "SimpleBuildersJacksonModule"; + + logger.info("Generating Jackson Module '%s' in package '%s'", moduleClassName, packageName); + + ClassName simpleModuleClass = + ClassName.get("com.fasterxml.jackson.databind.module", "SimpleModule"); + ClassName jsonDeserializeClass = + ClassName.get("com.fasterxml.jackson.databind.annotation", "JsonDeserialize"); + + // Create the constructor + MethodSpec.Builder constructorBuilder = + MethodSpec.constructorBuilder().addModifiers(Modifier.PUBLIC); + + // Create the class + TypeSpec.Builder classBuilder = + TypeSpec.classBuilder(moduleClassName) + .addModifiers(Modifier.PUBLIC) + .superclass(simpleModuleClass); + + for (JacksonModuleEntryDto entry : moduleDef.getEntries()) { + ClassName dtoClass = + ClassName.get(entry.dtoType().getPackageName(), entry.dtoType().getClassName()); + ClassName builderClass = + ClassName.get(entry.builderType().getPackageName(), entry.builderType().getClassName()); + + // Create MixIn interface name: DtoNameMixin + String mixinName = entry.dtoType().getClassName() + "Mixin"; + + // Create MixIn interface with @JsonDeserialize(builder = Builder.class) + TypeSpec mixinInterface = + TypeSpec.interfaceBuilder(mixinName) + .addModifiers(Modifier.PRIVATE) + .addAnnotation( + AnnotationSpec.builder(jsonDeserializeClass) + .addMember("builder", "$T.class", builderClass) + .build()) + .build(); + + classBuilder.addType(mixinInterface); + + // Add registration to constructor: setMixInAnnotation(Dto.class, Mixin.class) + constructorBuilder.addStatement( + "setMixInAnnotation($T.class, $N.class)", dtoClass, mixinName); + } + + classBuilder.addMethod(constructorBuilder.build()); + + // Write file + try { + writeSimpleClassToFile(packageName, classBuilder.build()); + } catch (BuilderException e) { + logger.warning( + "simple-builders: Error generating Jackson module for package %s: %s\n%s", + packageName, e.getMessage(), java.util.Arrays.toString(e.getStackTrace())); + } + } } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderConfigurationReaderTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderConfigurationReaderTest.java index c84a0e09..fa3cccdc 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderConfigurationReaderTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderConfigurationReaderTest.java @@ -420,6 +420,7 @@ void resolveConfiguration_AllLayersTogether_CompleteChain() { usingHashMapBuilder = OptionState.DISABLED, usingGeneratedAnnotation = OptionState.DISABLED, usingBuilderImplementationAnnotation = OptionState.DISABLED, + usingJacksonDeserializerAnnotation = OptionState.DISABLED, builderSuffix = "MinimalBuilder", setterSuffix = "with" )) diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java index 6ddaf5fd..4393af6f 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java @@ -79,6 +79,8 @@ void allConfigurationOptions_MustBeSettableViaBuilder() { // Integration .implementsBuilderBase(OptionState.ENABLED) .generateWithInterface(OptionState.ENABLED) + .usingJacksonDeserializerAnnotation(OptionState.ENABLED) + .generateJacksonModule(OptionState.ENABLED) // Naming .builderSuffix("Builder") .setterSuffix("") @@ -106,6 +108,8 @@ void allConfigurationOptions_MustBeSettableViaBuilder() { assertEquals(OptionState.ENABLED, config.usingBuilderImplementationAnnotation()); assertEquals(OptionState.ENABLED, config.implementsBuilderBase()); assertEquals(OptionState.ENABLED, config.generateWithInterface()); + assertEquals(OptionState.ENABLED, config.usingJacksonDeserializerAnnotation()); + assertEquals(OptionState.ENABLED, config.generateJacksonModule()); assertEquals("Builder", config.getBuilderSuffix()); assertEquals("", config.getSetterSuffix()); } @@ -210,6 +214,7 @@ public Address() {} "-Asimplebuilder.usingBuilderImplementationAnnotation=false", "-Asimplebuilder.implementsBuilderBase=false", "-Asimplebuilder.generateWithInterface=false", + "-Asimplebuilder.usingJacksonDeserializerAnnotation=false", "-Asimplebuilder.builderSuffix=CustomBuilder", "-Asimplebuilder.setterSuffix=with") .compile(nestedDto, addressDto, source); @@ -285,6 +290,9 @@ public Address() {} ProcessorAsserts.assertNotContaining( generatedCode, "implements IBuilderBase", "@Override public MinimalDto build()"); + // With usingJacksonDeserializerAnnotation=false, NO @JsonPOJOBuilder annotation should be used + ProcessorAsserts.assertNotContaining(generatedCode, "@JsonPOJOBuilder"); + // With builderAccess=PACKAGE_PRIVATE, builder class should NOT have public modifier ProcessorAsserts.assertNotContaining(generatedCode, "public class MinimalDtoCustomBuilder"); diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/JacksonModuleMultiPackageTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/JacksonModuleMultiPackageTest.java new file mode 100644 index 00000000..3cd791e5 --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/JacksonModuleMultiPackageTest.java @@ -0,0 +1,145 @@ +package org.javahelpers.simple.builders.processor; + +import static com.google.testing.compile.CompilationSubject.assertThat; +import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.assertContaining; + +import com.google.testing.compile.Compilation; +import javax.tools.JavaFileObject; +import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils; +import org.junit.jupiter.api.Test; + +class JacksonModuleMultiPackageTest { + + private static String extractContent(JavaFileObject fileObject) { + try { + return fileObject.getCharContent(false).toString(); + } catch (Exception e) { + return fileObject.toString(); + } + } + + @Test + void generateJacksonModule_WithMultiplePackages_ShouldGenerateMultipleModules() { + // Given: Two DTOs in different packages, default configuration + JavaFileObject dto1 = + ProcessorTestUtils.simpleBuilderClass( + "pkg.one", + "DtoOne", + """ + private String name; + public String getName() { return name; } + public void setName(String name) { this.name = name; } + """); + + JavaFileObject dto2 = + ProcessorTestUtils.simpleBuilderClass( + "pkg.two", + "DtoTwo", + """ + private int value; + public int getValue() { return value; } + public void setValue(int value) { this.value = value; } + """); + + // When: Compile with Jackson module generation enabled, no global package set + Compilation compilation = + ProcessorTestUtils.createCompiler() + .withOptions( + "-Asimplebuilder.generateJacksonModule=true", + "-Asimplebuilder.usingJacksonDeserializerAnnotation=true") + .compile(dto1, dto2); + + // Then: Should generate TWO modules, one in each package + assertThat(compilation).succeeded(); + + // Verify module in pkg.one + JavaFileObject module1 = + compilation.generatedSourceFile("pkg.one.SimpleBuildersJacksonModule").orElseThrow(); + String pkgOneContent = extractContent(module1); + assertContaining( + pkgOneContent, + "package pkg.one;", + "import com.fasterxml.jackson.databind.annotation.JsonDeserialize;", + "import com.fasterxml.jackson.databind.module.SimpleModule;", + "public class SimpleBuildersJacksonModule extends SimpleModule", + "setMixInAnnotation(DtoOne.class, DtoOneMixin.class)", + "@JsonDeserialize", + "builder = DtoOneBuilder.class", + "private interface DtoOneMixin"); + + // Verify module in pkg.two + JavaFileObject module2 = + compilation.generatedSourceFile("pkg.two.SimpleBuildersJacksonModule").orElseThrow(); + String pkgTwoContent = extractContent(module2); + assertContaining( + pkgTwoContent, + "package pkg.two;", + "import com.fasterxml.jackson.databind.annotation.JsonDeserialize;", + "import com.fasterxml.jackson.databind.module.SimpleModule;", + "public class SimpleBuildersJacksonModule extends SimpleModule", + "setMixInAnnotation(DtoTwo.class, DtoTwoMixin.class)", + "@JsonDeserialize", + "builder = DtoTwoBuilder.class", + "private interface DtoTwoMixin"); + } + + @Test + void generateJacksonModule_WithAnnotationOverride_ShouldRespectOverride() { + // Given: DTO with specific package override via annotation + // Note: We simulate this by having one DTO use default (its package) and another potentially + // using a different one if we could mocking the annotation value, + // but here we test the "default package" vs "configured package" behavior. + // Actually, testing annotation override specifically requires a DTO with + // @SimpleBuilder.Options(jacksonModulePackage="...") + // But SimpleBuilder.Options doesn't expose jacksonModulePackage yet in the annotation + // definition? + // Wait, we added it to BuilderConfiguration but did we add it to the @SimpleBuilder.Options + // annotation? + // Checking SimpleBuilder.java... + + // We need to construct the source file manually to include the annotation with options + // because simpleBuilderClass doesn't support adding options easily + JavaFileObject sourceWithAnnotation = + ProcessorTestUtils.forSource( + """ + package pkg.source; + + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + import org.javahelpers.simple.builders.core.enums.OptionState; + + @SimpleBuilder(options = @SimpleBuilder.Options( + generateJacksonModule = OptionState.ENABLED, + usingJacksonDeserializerAnnotation = OptionState.ENABLED, + jacksonModulePackage = "pkg.target" + )) + public class DtoWithOverride { + private String name; + public String getName() { return name; } + public void setName(String name) { this.name = name; } + } + """); + + // When + Compilation compilation = ProcessorTestUtils.createCompiler().compile(sourceWithAnnotation); + + // Then + assertThat(compilation).succeeded(); + + // Verify module in pkg.target (not pkg.source) + JavaFileObject module3 = + compilation.generatedSourceFile("pkg.target.SimpleBuildersJacksonModule").orElseThrow(); + String pkgTargetContent = extractContent(module3); + assertContaining( + pkgTargetContent, + "package pkg.target;", + "import com.fasterxml.jackson.databind.annotation.JsonDeserialize;", + "import com.fasterxml.jackson.databind.module.SimpleModule;", + "import pkg.source.DtoWithOverride;", + "import pkg.source.DtoWithOverrideBuilder;", + "public class SimpleBuildersJacksonModule extends SimpleModule", + "setMixInAnnotation(DtoWithOverride.class, DtoWithOverrideMixin.class)", + "@JsonDeserialize", + "builder = DtoWithOverrideBuilder.class", + "private interface DtoWithOverrideMixin"); + } +} diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/JacksonModuleTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/JacksonModuleTest.java new file mode 100644 index 00000000..52e57297 --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/JacksonModuleTest.java @@ -0,0 +1,92 @@ +package org.javahelpers.simple.builders.processor; + +import static com.google.testing.compile.CompilationSubject.assertThat; + +import com.google.testing.compile.Compilation; +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; + +class JacksonModuleTest { + + @Test + void generateJacksonModule_WhenEnabled_ShouldGenerateModuleClass() { + // Given + JavaFileObject dto1 = + ProcessorTestUtils.simpleBuilderClass( + "test", + "FirstDto", + """ + private String name; + public String getName() { return name; } + public void setName(String name) { this.name = name; } + """); + + JavaFileObject dto2 = + ProcessorTestUtils.simpleBuilderClass( + "test", + "SecondDto", + """ + private int age; + public int getAge() { return age; } + public void setAge(int age) { this.age = age; } + """); + + // When + Compilation compilation = + ProcessorTestUtils.createCompiler() + .withOptions( + "-Asimplebuilder.generateJacksonModule=true", + "-Asimplebuilder.usingJacksonDeserializerAnnotation=true") + .compile(dto1, dto2); + + // Then + assertThat(compilation).succeeded(); + + // Check for generated module class + // It should be in the same package as the first DTO ("test") + String generatedModule = + ProcessorTestUtils.loadGeneratedSource(compilation, "SimpleBuildersJacksonModule"); + + ProcessorAsserts.assertContaining( + generatedModule, + "package test;", + "public class SimpleBuildersJacksonModule extends SimpleModule", + "setMixInAnnotation(FirstDto.class, FirstDtoMixin.class)", + "setMixInAnnotation(SecondDto.class, SecondDtoMixin.class)", + "@JsonDeserialize(builder = FirstDtoBuilder.class)", + "@JsonDeserialize(builder = SecondDtoBuilder.class)"); + } + + @Test + void generateJacksonModule_WhenDisabled_ShouldNotGenerateModuleClass() { + // Given + JavaFileObject dto = + ProcessorTestUtils.simpleBuilderClass( + "test", + "ModuleDisabledDto", + """ + private String name; + public String getName() { return name; } + public void setName(String name) { this.name = name; } + """); + + // When + Compilation compilation = + ProcessorTestUtils.createCompiler() + .withOptions("-Asimplebuilder.generateJacksonModule=false") + .compile(dto); + + // Then + assertThat(compilation).succeeded(); + + // Verify file is NOT generated + try { + ProcessorTestUtils.loadGeneratedSource(compilation, "SimpleBuildersJacksonModule"); + throw new AssertionError("SimpleBuildersJacksonModule should not have been generated"); + } catch (AssertionError e) { + // Expected + } + } +} diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/JacksonModuleWarningTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/JacksonModuleWarningTest.java new file mode 100644 index 00000000..7f871006 --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/JacksonModuleWarningTest.java @@ -0,0 +1,75 @@ +package org.javahelpers.simple.builders.processor; + +import static com.google.testing.compile.CompilationSubject.assertThat; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.createCompiler; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.simpleBuilderClass; + +import com.google.testing.compile.Compilation; +import javax.tools.JavaFileObject; +import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils; +import org.junit.jupiter.api.Test; + +class JacksonModuleWarningTest { + + @Test + void generateJacksonModuleEnabled_ButAnnotationDisabled_ShouldEmitWarningAndSkipGeneration() { + // Given + JavaFileObject dto = + simpleBuilderClass( + "pkg.test", + "MisconfiguredDto", + """ + private String name; + public String getName() { return name; } + public void setName(String name) { this.name = name; } + """); + + // When + // Enable generateJacksonModule but NOT usingJacksonDeserializerAnnotation (which defaults to + // DISABLED) + Compilation compilation = + createCompiler().withOptions("-Asimplebuilder.generateJacksonModule=true").compile(dto); + + // Then + assertThat(compilation).succeeded(); + + // Should have a warning + assertThat(compilation) + .hadWarningContaining( + "simple-builders: generateJacksonModule is enabled but usingJacksonDeserializerAnnotation is disabled"); + + // Should NOT have generated the Jackson module + // We verify this by ensuring the warning is present, which implies the generation branch was + // skipped. + // Testing explicitly for "file not generated" is tricky with compile-testing without complex + // custom assertions. + } + + @Test + void generateJacksonModule_WhenEnabledAndDeserializerEnabled_ShouldGenerate() { + // Given + JavaFileObject dto = + ProcessorTestUtils.simpleBuilderClass( + "test", + "SuccessDto", + """ + private String name; + public String getName() { return name; } + public void setName(String name) { this.name = name; } + """); + + // When + Compilation compilation = + ProcessorTestUtils.createCompiler() + .withOptions( + "-Asimplebuilder.generateJacksonModule=true", + "-Asimplebuilder.usingJacksonDeserializerAnnotation=true") + .compile(dto); + + // Then + assertThat(compilation).succeeded(); + + // Verify file IS generated + ProcessorTestUtils.loadGeneratedSource(compilation, "SimpleBuildersJacksonModule"); + } +} diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/JacksonSupportTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/JacksonSupportTest.java new file mode 100644 index 00000000..e77850dc --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/JacksonSupportTest.java @@ -0,0 +1,128 @@ +package org.javahelpers.simple.builders.processor; + +import static com.google.testing.compile.CompilationSubject.assertThat; + +import com.google.testing.compile.Compilation; +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; + +class JacksonSupportTest { + + @Test + void usingJacksonDeserializerAnnotation_WhenEnabled_ShouldGenerateAnnotation() { + // Given + JavaFileObject source = + ProcessorTestUtils.simpleBuilderClass( + "test", + "JacksonDto", + """ + private String name; + public String getName() { return name; } + public void setName(String name) { this.name = name; } + """); + + // When + Compilation compilation = + ProcessorTestUtils.createCompiler() + .withOptions("-Asimplebuilder.usingJacksonDeserializerAnnotation=true") + .compile(source); + + // Then + assertThat(compilation).succeeded(); + String generatedCode = ProcessorTestUtils.loadGeneratedSource(compilation, "JacksonDtoBuilder"); + + ProcessorAsserts.assertContaining( + generatedCode, + "import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;", + "@JsonPOJOBuilder(withPrefix = \"\")"); + } + + @Test + void + usingJacksonDeserializerAnnotation_WhenEnabledWithCustomPrefix_ShouldGenerateAnnotationWithPrefix() { + // Given + JavaFileObject source = + ProcessorTestUtils.simpleBuilderClass( + "test", + "JacksonCustomDto", + """ + private String name; + public String getName() { return name; } + public void setName(String name) { this.name = name; } + """); + + // When + Compilation compilation = + ProcessorTestUtils.createCompiler() + .withOptions( + "-Asimplebuilder.usingJacksonDeserializerAnnotation=true", + "-Asimplebuilder.setterSuffix=with") + .compile(source); + + // Then + assertThat(compilation).succeeded(); + String generatedCode = + ProcessorTestUtils.loadGeneratedSource(compilation, "JacksonCustomDtoBuilder"); + + ProcessorAsserts.assertContaining(generatedCode, "@JsonPOJOBuilder(withPrefix = \"with\")"); + } + + @Test + void usingJacksonDeserializerAnnotation_WhenDisabled_ShouldNotGenerateAnnotation() { + // Given + JavaFileObject source = + ProcessorTestUtils.simpleBuilderClass( + "test", + "NoJacksonDto", + """ + private String name; + public String getName() { return name; } + public void setName(String name) { this.name = name; } + """); + + // When + Compilation compilation = + ProcessorTestUtils.createCompiler() + // usingJacksonDeserializerAnnotation is disabled by default, but we can explicitly + // disable + // it + .withOptions("-Asimplebuilder.usingJacksonDeserializerAnnotation=false") + .compile(source); + + // Then + assertThat(compilation).succeeded(); + String generatedCode = + ProcessorTestUtils.loadGeneratedSource(compilation, "NoJacksonDtoBuilder"); + + ProcessorAsserts.assertNotContaining(generatedCode, "JsonPOJOBuilder"); + } + + @Test + void usingJacksonDeserializerAnnotation_WhenNotConfigured_ShouldDefaultToDisabled() { + // Given + JavaFileObject source = + ProcessorTestUtils.simpleBuilderClass( + "test", + "DefaultJacksonDto", + """ + private String name; + public String getName() { return name; } + public void setName(String name) { this.name = name; } + """); + + // When + Compilation compilation = + ProcessorTestUtils.createCompiler() + // No options provided + .compile(source); + + // Then + assertThat(compilation).succeeded(); + String generatedCode = + ProcessorTestUtils.loadGeneratedSource(compilation, "DefaultJacksonDtoBuilder"); + + ProcessorAsserts.assertNotContaining(generatedCode, "JsonPOJOBuilder"); + } +}