Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions options.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,58 @@ Special handling for collections. See the project test classes for usage.
| `@RecordBuilder.Options(useUnmodifiableCollections = true/false)` | Adds special handling for collection record components. The default is `false`. |
| `@RecordBuilder.Options(allowNullableCollections = true/false)` | Adds special null handling for record collectioncomponents. The default is `false`. |
| `@RecordBuilder.Options(addSingleItemCollectionBuilders = true/false)` | Adds special handling for record collectioncomponents. The default is `false`. |

## Jackson Support

RecordBuilder can automatically add Jackson annotations to generated builders, supporting both Jackson 2.x and 3.x. Configuration is done via the nested `@JacksonConfig` annotation.

### Basic Example

```java
@RecordBuilder
@RecordBuilder.Options(
jackson = @RecordBuilder.JacksonConfig(jsonPOJOBuilder = true)
)
@JsonDeserialize(builder = UserRecordBuilder.class)
record UserRecord(String name, int age) {}
```

### Configuration Options

| option | details |
|--------|---------|
| `jackson = @JacksonConfig(...)` | Configures Jackson annotation support for the generated builder. By default, no Jackson annotations are added. |

### JacksonConfig Properties

| property | details |
|----------|---------|
| `jsonPOJOBuilder` | **boolean** (default: `false`) - When `true`, adds `@JsonPOJOBuilder` annotation to the generated builder. This annotation works with `@JsonDeserialize(builder = ...)` on the record. |
| `version` | **JacksonVersion** (default: `AUTO`) - Specifies which Jackson version to use:<br/>• `AUTO` - Automatically detect Jackson version(s) on classpath and add all found annotations. If both Jackson 2.x and 3.x are present, annotations for both versions will be added.<br/>• `JACKSON_2` - Only add Jackson 2.x annotations (`com.fasterxml.jackson.*`). Fails if Jackson 2.x is not found.<br/>• `JACKSON_3` - Only add Jackson 3.x annotations (`tools.jackson.*`). Fails if Jackson 3.x is not found. |

### Examples

#### Auto-detect Jackson version (default)
```java
@RecordBuilder.Options(jackson = @RecordBuilder.JacksonConfig(jsonPOJOBuilder = true))
```

#### Explicit Jackson 2.x
```java
@RecordBuilder.Options(
jackson = @RecordBuilder.JacksonConfig(
jsonPOJOBuilder = true,
version = JacksonVersion.JACKSON_2
)
)
```

#### With custom setter prefix
```java
@RecordBuilder.Options(
jackson = @RecordBuilder.JacksonConfig(jsonPOJOBuilder = true),
setterPrefix = "set"
)
```

See [TestJacksonAnnotations](./record-builder-test/src/test/java/io/soabase/recordbuilder/test/TestJacksonAnnotations.java) for complete examples.
13 changes: 13 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
<hibernate-validator-version>6.2.0.Final</hibernate-validator-version>
<jakarta-validation-api-version>3.1.0</jakarta-validation-api-version>
<javax-el-version>3.0.1-b09</javax-el-version>
<jackson2-version>2.21.0</jackson2-version>
<jackson3-version>3.0.4</jackson3-version>
<central-publishing-maven-plugin-version>0.7.0</central-publishing-maven-plugin-version>
<jspecify-version>1.0.0</jspecify-version>
<lombok-version>1.18.42</lombok-version>
Expand Down Expand Up @@ -167,6 +169,17 @@
<version>${hibernate-validator-version}</version>
</dependency>

<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson2-version}</version>
</dependency>
<dependency>
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: add a new line here

<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson3-version}</version>
</dependency>

<dependency>
<groupId>org.glassfish</groupId>
<artifactId>javax.el</artifactId>
Expand Down
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please squash the two commits

Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,31 @@
* @see #nullablePattern
*/
boolean defaultNotNull() default false;

/**
* Configuration for Jackson annotation support on generated builders.
*
* @see JacksonConfig
*/
JacksonConfig jackson() default @JacksonConfig;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
JacksonConfig jackson() default @JacksonConfig;
JacksonVersion jackson() default JacksonVersion.NONE;

}

/**
* Configuration for Jackson annotation support on generated builders.
*/
@Retention(RetentionPolicy.CLASS)
@Target(ElementType.ANNOTATION_TYPE)
@interface JacksonConfig {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment below. We can remove this annotation.

/**
* Add {@code @JsonPOJOBuilder} annotation to generated builder. This annotation works with
* {@code @JsonDeserialize(builder = ...)} on the record.
*/
boolean jsonPOJOBuilder() default false;

/**
* Which Jackson version to use for annotations.
*/
JacksonVersion version() default JacksonVersion.AUTO;
}

@Retention(RetentionPolicy.CLASS)
Expand All @@ -378,6 +403,29 @@ enum ConcreteSettersForOptionalMode {
DISABLED, ENABLED, ENABLED_WITH_NULLABLE_ANNOTATION,
}

/**
* Specifies which Jackson version(s) to use when generating builder annotations.
*/
enum JacksonVersion {
/**
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add a NONE attribute here and remove @JacksonConfig. I'd also rename this RecordBuilderJacksonVersion or something to avoid name collisions with other projects (I realize some other names in here suffer from this).

* Automatically detect Jackson version(s) on classpath and add all found annotations. If both Jackson 2.x and
* 3.x are present, both annotations will be added.
*/
AUTO,

/**
* Only add Jackson 2.x annotations (com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder). Fails if
* Jackson 2.x is not found on classpath.
*/
JACKSON_2,

/**
* Only add Jackson 3.x annotations (tools.jackson.databind.annotation.JsonPOJOBuilder). Fails if Jackson 3.x is
* not found on classpath.
*/
JACKSON_3,
}

/**
* Apply to record components to specify a field initializer for the generated builder
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ class InternalRecordBuilderProcessor {
builder.addAnnotation(recordBuilderGeneratedAnnotation);
}

new JacksonSupport(processingEnv).addJacksonAnnotations(metaData, builder);

if (!validateMethodNameConflicts(processingEnv, recordFacade.element())) {
builderType = Optional.empty();
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* Copyright 2019 The original author or authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.soabase.recordbuilder.processor;

import com.palantir.javapoet.AnnotationSpec;
import com.palantir.javapoet.ClassName;
import com.palantir.javapoet.TypeSpec;
import io.soabase.recordbuilder.core.RecordBuilder;

import javax.annotation.processing.ProcessingEnvironment;

import static javax.tools.Diagnostic.Kind.ERROR;

class JacksonSupport {
private static final String JACKSON_2_ANNOTATION_PACKAGE = "com.fasterxml.jackson.databind.annotation";
private static final String JACKSON_3_ANNOTATION_PACKAGE = "tools.jackson.databind.annotation";

private static final String JSON_POJO_BUILDER = "JsonPOJOBuilder";

private final ProcessingEnvironment processingEnv;
private final boolean jackson2Present;
private final boolean jackson3Present;

JacksonSupport(ProcessingEnvironment processingEnv) {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given how this is used it would be better as a utility class with a private ctor and the methods becoming static.

this.processingEnv = processingEnv;
jackson2Present = isAnnotationClassPresent(JACKSON_2_ANNOTATION_PACKAGE, JSON_POJO_BUILDER);
jackson3Present = isAnnotationClassPresent(JACKSON_3_ANNOTATION_PACKAGE, JSON_POJO_BUILDER);
}

private boolean isAnnotationClassPresent(String packageName, String className) {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

className is always JSON_POJO_BUILDER

return processingEnv.getElementUtils().getTypeElement(packageName + "." + className) != null;
}

public void addJacksonAnnotations(RecordBuilder.Options metaData, TypeSpec.Builder builder) {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can be package-private

// return without further processing if no annotation is enabled
if (!anyJacksonAnnotationEnabled(metaData)) {
return;
}
Comment on lines +49 to +51
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unnecessary right?


switch (metaData.jackson().version()) {
case AUTO -> {
if (!jackson2Present && !jackson3Present) {
processingEnv.getMessager().printMessage(ERROR,
"jackson.jsonPOJOBuilder is enabled but Jackson is not found on classpath. "
+ "Add jackson-databind dependency or disable jsonPOJOBuilder.");
return;
}

if (jackson2Present) {
addJacksonAnnotations(metaData, builder, JACKSON_2_ANNOTATION_PACKAGE);
}

if (jackson3Present) {
addJacksonAnnotations(metaData, builder, JACKSON_3_ANNOTATION_PACKAGE);
}
}

case JACKSON_2 -> {
if (!jackson2Present) {
processingEnv.getMessager().printMessage(ERROR,
"jackson.version is set to JACKSON_2 but Jackson 2.x is not found on classpath. "
+ "Add jackson-databind 2.x dependency or change version to AUTO.");
return;
}

addJacksonAnnotations(metaData, builder, JACKSON_2_ANNOTATION_PACKAGE);
}

case JACKSON_3 -> {
if (!jackson3Present) {
processingEnv.getMessager().printMessage(ERROR,
"jackson.version is set to JACKSON_3 but Jackson 3.x is not found on classpath. "
+ "Add jackson-databind 3.x dependency or change version to AUTO.");
return;
}

addJacksonAnnotations(metaData, builder, JACKSON_3_ANNOTATION_PACKAGE);
}
Comment on lines +54 to +91
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove those returns

Suggested change
case AUTO -> {
if (!jackson2Present && !jackson3Present) {
processingEnv.getMessager().printMessage(ERROR,
"jackson.jsonPOJOBuilder is enabled but Jackson is not found on classpath. "
+ "Add jackson-databind dependency or disable jsonPOJOBuilder.");
return;
}
if (jackson2Present) {
addJacksonAnnotations(metaData, builder, JACKSON_2_ANNOTATION_PACKAGE);
}
if (jackson3Present) {
addJacksonAnnotations(metaData, builder, JACKSON_3_ANNOTATION_PACKAGE);
}
}
case JACKSON_2 -> {
if (!jackson2Present) {
processingEnv.getMessager().printMessage(ERROR,
"jackson.version is set to JACKSON_2 but Jackson 2.x is not found on classpath. "
+ "Add jackson-databind 2.x dependency or change version to AUTO.");
return;
}
addJacksonAnnotations(metaData, builder, JACKSON_2_ANNOTATION_PACKAGE);
}
case JACKSON_3 -> {
if (!jackson3Present) {
processingEnv.getMessager().printMessage(ERROR,
"jackson.version is set to JACKSON_3 but Jackson 3.x is not found on classpath. "
+ "Add jackson-databind 3.x dependency or change version to AUTO.");
return;
}
addJacksonAnnotations(metaData, builder, JACKSON_3_ANNOTATION_PACKAGE);
}
case AUTO -> {
if (!jackson2Present && !jackson3Present) {
processingEnv.getMessager().printMessage(ERROR,
"jackson.jsonPOJOBuilder is enabled but Jackson is not found on classpath. "
+ "Add jackson-databind dependency or disable jsonPOJOBuilder.");
}
else if (jackson2Present) {
addJacksonAnnotations(metaData, builder, JACKSON_2_ANNOTATION_PACKAGE);
}
else /*if (jackson3Present)*/ {
addJacksonAnnotations(metaData, builder, JACKSON_3_ANNOTATION_PACKAGE);
}
}
case JACKSON_2 -> {
if (!jackson2Present) {
processingEnv.getMessager().printMessage(ERROR,
"jackson.version is set to JACKSON_2 but Jackson 2.x is not found on classpath. "
+ "Add jackson-databind 2.x dependency or change version to AUTO.");
}
else {
addJacksonAnnotations(metaData, builder, JACKSON_2_ANNOTATION_PACKAGE);
}
}
case JACKSON_3 -> {
if (!jackson3Present) {
processingEnv.getMessager().printMessage(ERROR,
"jackson.version is set to JACKSON_3 but Jackson 3.x is not found on classpath. "
+ "Add jackson-databind 3.x dependency or change version to AUTO.");
} else {
addJacksonAnnotations(metaData, builder, JACKSON_3_ANNOTATION_PACKAGE);
}
}

}
}

private boolean anyJacksonAnnotationEnabled(RecordBuilder.Options metaData) {
return metaData.jackson().jsonPOJOBuilder();
}

private void addJacksonAnnotations(RecordBuilder.Options metaData, TypeSpec.Builder builder, String packageName) {
if (metaData.jackson().jsonPOJOBuilder()) {
addJsonPOJOBuilderAnnotation(metaData, builder, packageName);
}
}

private void addJsonPOJOBuilderAnnotation(RecordBuilder.Options metaData, TypeSpec.Builder builder,
String packageName) {
final var annotationSpec = AnnotationSpec.builder(ClassName.get(packageName, JSON_POJO_BUILDER))
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also need to consider metaData.builderMethodName() and metaData.withClassMethodPrefix() right? JsonPOJOBuilder has attributes for that.

.addMember("withPrefix", "$S", metaData.setterPrefix()).build();

builder.addAnnotation(annotationSpec);
}
}
9 changes: 9 additions & 0 deletions record-builder-test/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@
<scope>provided</scope>
</dependency>

<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>

<dependency>
<groupId>org.glassfish</groupId>
<artifactId>javax.el</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright 2019 The original author or authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.soabase.recordbuilder.test;

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import io.soabase.recordbuilder.core.RecordBuilder;

import java.util.Map;

public interface JacksonAnnotated {
String name();

String type();

Map<String, Object> properties();

@RecordBuilder
@RecordBuilder.Options(jackson = @RecordBuilder.JacksonConfig(jsonPOJOBuilder = true), useImmutableCollections = true, prefixEnclosingClassNames = false)
@JsonDeserialize(builder = JacksonAnnotatedRecordBuilder.class)
record JacksonAnnotatedRecord(String name, @RecordBuilder.Initializer("DEFAULT_TYPE") String type,
Map<String, Object> properties) implements JacksonAnnotated {
public static final String DEFAULT_TYPE = "dummy";
}

@RecordBuilder
@RecordBuilder.Options(jackson = @RecordBuilder.JacksonConfig(jsonPOJOBuilder = true), useImmutableCollections = true, prefixEnclosingClassNames = false, setterPrefix = "set")
@JsonDeserialize(builder = JacksonAnnotatedRecordCustomSetterPrefixBuilder.class)
record JacksonAnnotatedRecordCustomSetterPrefix(String name, @RecordBuilder.Initializer("DEFAULT_TYPE") String type,
Map<String, Object> properties) implements JacksonAnnotated {
public static final String DEFAULT_TYPE = "dummy";
}

@RecordBuilder
@RecordBuilder.Options(jackson = @RecordBuilder.JacksonConfig(jsonPOJOBuilder = true, version = RecordBuilder.JacksonVersion.JACKSON_2), useImmutableCollections = true, prefixEnclosingClassNames = false)
@JsonDeserialize(builder = JacksonAnnotatedRecordJackson2Builder.class)
record JacksonAnnotatedRecordJackson2(String name, @RecordBuilder.Initializer("DEFAULT_TYPE") String type,
Map<String, Object> properties) implements JacksonAnnotated {
public static final String DEFAULT_TYPE = "dummy";
}

@RecordBuilder
@RecordBuilder.Options(jackson = @RecordBuilder.JacksonConfig(jsonPOJOBuilder = true, version = RecordBuilder.JacksonVersion.JACKSON_3), useImmutableCollections = true, prefixEnclosingClassNames = false)
@tools.jackson.databind.annotation.JsonDeserialize(builder = JacksonAnnotatedRecordJackson3Builder.class)
record JacksonAnnotatedRecordJackson3(String name, @RecordBuilder.Initializer("DEFAULT_TYPE") String type,
Map<String, Object> properties) implements JacksonAnnotated {
public static final String DEFAULT_TYPE = "dummy";
}

@RecordBuilder
@RecordBuilder.Options(prefixEnclosingClassNames = false)
record JacksonAnnotatedRecordNoJackson(String name, String type, Map<String, Object> properties)
implements JacksonAnnotated {
}
}
Loading