Compare commits

...

6 Commits

Author SHA1 Message Date
Jordan Zimmerman
a2edd7299f [maven-release-plugin] prepare release record-builder-1.8.ea 2020-09-27 22:23:22 -05:00
Jordan Zimmerman
6661c2ae0e Added support for withers and moved to Java 15 2020-09-27 22:18:26 -05:00
Jordan Zimmerman
74c8480b43 Update README.md 2020-08-03 08:48:59 -05:00
Jordan Zimmerman
8dbdb43391 Update README.md 2020-05-30 08:21:30 -05:00
Jordan Zimmerman
44064d656e Update README.md 2020-05-28 21:38:31 -05:00
Jordan Zimmerman
791eb02faf [maven-release-plugin] prepare for next development iteration 2020-05-28 21:36:11 -05:00
12 changed files with 330 additions and 54 deletions

View File

@@ -1,3 +1,3 @@
language: java
jdk:
- openjdk14
- openjdk15

View File

@@ -5,13 +5,20 @@
## What is RecordBuilder
Java 14 is introducing [Records](https://cr.openjdk.java.net/~briangoetz/amber/datum.html) as a preview feature. Since Java 9, features in Java are being released in stages. While the Java 14 version of records is fantastic, it's currently missing an important feature for data classes: a builder. This project is an annotation processor that creates companion builder classes for Java records.
Java 15 introduced [Records](https://cr.openjdk.java.net/~briangoetz/amber/datum.html) as a preview feature. Since Java 9,
features in Java are being released in stages. While the Java 15 version of records is fantastic, it's currently missing important features
for data classes: a builder and "with"ers. This project is an annotation processor that creates:
- a companion builder class for Java records
- an interface that adds "with" copy methods
- an annotation that generates a Java record from an Interface template
In addition to a record builder an annotation is provided that can generate a Java record from an Interface template. This will be useful for DAO-style interfaces, etc.
where a Record (with toString(), hashCode(), equals(), etc.) and a companion RecordBuilder are needed.
_Details:_
- [RecordBuilder Details](#RecordBuilder-Example)
- [Record From Interface Details](#Record-Interface-Example)
- [Wither Details](#Wither-Example)
- [RecordBuilder Full Definition](#Builder-Class-Definition)
- [Record From Interface Details](#RecordInterface-Example)
## RecordBuilder Example
@@ -36,6 +43,28 @@ setAge(builder);
var n3 = builder.build();
```
## Wither Example
```java
@RecordBuilder
public record NameAndAge(String name, int age) implements NameAndAgeBuilder.With {}
```
In addition to creating a builder, your record is enhanced by "wither" methods ala:
```java
var r1 = new NameAndAge("foo", 123);
var r2 = r1.withName("bar");
var r3 = r2.withAge(456);
// access the builder as well:
var r4 = r3.with().age(101).name("baz").build();
```
_Hat tip to [Benji Weber](https://benjiweber.co.uk/blog/2020/09/19/fun-with-java-records/) for the Withers idea._
## Builder Class Definition
The full builder class is defined as:
```java
@@ -127,6 +156,43 @@ public class NameAndAgeBuilder {
&& Objects.equals(name, b.name)
&& (age == b.age));
}
/**
* Add withers to {@code NameAndAge}
*/
public interface With {
/**
* Cast this to the record type
*/
default NameAndAge internalGetThis() {
Object obj = this;
return (PersonRecord)obj;
}
/**
* Return a new record builder using the current values
*/
default NameAndAgeBuilder with() {
NameAndAge r = internalGetThis();
return NameAndAgeBuilder.builder(r);
}
/**
* Return a new instance of {@code NameAndAge} with a new value for {@code name}
*/
default NameAndAge withName(String name) {
NameAndAge r = internalGetThis();
return new NameAndAge(name, r.age());
}
/**
* Return a new instance of {@code NameAndAge} with a new value for {@code age}
*/
default NameAndAge withAge(int age) {
NameAndAge r = internalGetThis();
return new NameAndAge(r.name(), age);
}
}
}
```
@@ -144,7 +210,7 @@ This will generate a record ala:
```java
@RecordBuilder
public record NameAndAgeRecord(String name, int age){}
public record NameAndAgeRecord(String name, int age) implements NameAndAge {}
```
Note that the generated record is annotated with `@RecordBuilder` so a record
@@ -195,7 +261,7 @@ Notes:
<!-- "release" and "enable-preview" are required while records are preview features -->
<release>14</release>
<release>15</release>
<compilerArgs>
<arg>--enable-preview</arg>
</compilerArgs>
@@ -237,11 +303,11 @@ Depending on your IDE you are likely to need to enable Annotation Processing in
Note: records are a preview feature only. You'll need take a number of steps in order to try RecordBuilder:
- Install and make active Java 14 or later
- Make sure your development tool is using Java 14 or later and is configured to enable preview features (for Maven I've documented how to do this here: [https://stackoverflow.com/a/59363152/2048051](https://stackoverflow.com/a/59363152/2048051))
- Install and make active Java 15 or later
- Make sure your development tool is using Java 15 or later and is configured to enable preview features (for Maven I've documented how to do this here: [https://stackoverflow.com/a/59363152/2048051](https://stackoverflow.com/a/59363152/2048051))
- Bear in mind that this is not yet meant for production and there are numerous bugs in the tools and JDKs.
Note: I've seen some very odd compilation bugs with the current Java 14 and Maven. If you get internal Javac errors I suggest rebuilding with `mvn clean package` and/or `mvn clean install`.
Note: I've seen some very odd compilation bugs with the current Java 15 and Maven. If you get internal Javac errors I suggest rebuilding with `mvn clean package` and/or `mvn clean install`.
## Customizing
@@ -259,6 +325,9 @@ Alternatively, you can provide values for each individual meta data (or combinat
- `javac ... -AbuilderMethodName=foo`
- `javac ... -AbuildMethodName=foo`
- `javac ... -AcomponentsMethodName=foo`
- `javac ... -AwithClassName=foo`
- `javac ... -AwithClassMethodPrefix=foo`
- `javac ... -AwithClassGetThisMethodName=foo`
- `javac ... -AfileComment=foo`
- `javac ... -AfileIndent=foo`
- `javac ... -AprefixEnclosingClassNames=foo`

21
pom.xml
View File

@@ -5,7 +5,7 @@
<groupId>io.soabase.record-builder</groupId>
<artifactId>record-builder</artifactId>
<packaging>pom</packaging>
<version>1.7.ea</version>
<version>1.8.ea</version>
<modules>
<module>record-builder-core</module>
@@ -18,7 +18,7 @@
<project.build.resourceEncoding>UTF-8</project.build.resourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<jdk-version>14</jdk-version>
<jdk-version>15</jdk-version>
<maven-compiler-plugin-version>3.8.1</maven-compiler-plugin-version>
<maven-source-plugin-version>3.2.0</maven-source-plugin-version>
@@ -30,6 +30,7 @@
<maven-clean-plugin-version>3.1.0</maven-clean-plugin-version>
<maven-shade-plugin-version>3.2.1</maven-shade-plugin-version>
<maven-release-plugin-version>2.5.3</maven-release-plugin-version>
<maven-surefire-plugin-version>3.0.0-M5</maven-surefire-plugin-version>
<javapoet-version>1.12.1</javapoet-version>
<junit-jupiter-version>5.5.2</junit-jupiter-version>
@@ -70,7 +71,7 @@
<url>https://github.com/randgalt/record-builder</url>
<connection>scm:git:https://github.com/randgalt/record-builder.git</connection>
<developerConnection>scm:git:git@github.com:randgalt/record-builder.git</developerConnection>
<tag>record-builder-1.7.ea</tag>
<tag>record-builder-1.8.ea</tag>
</scm>
<issueManagement>
@@ -271,6 +272,15 @@
<tagNameFormat>record-builder-@{project.version}</tagNameFormat>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven-surefire-plugin-version}</version>
<configuration>
<argLine>--enable-preview</argLine>
</configuration>
</plugin>
</plugins>
</pluginManagement>
@@ -278,11 +288,6 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>14</source>
<target>14</target>
<compilerArgs>--enable-preview</compilerArgs>
</configuration>
</plugin>
<plugin>

View File

@@ -3,22 +3,9 @@
<parent>
<groupId>io.soabase.record-builder</groupId>
<artifactId>record-builder</artifactId>
<version>1.7.ea</version>
<version>1.8.ea</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>record-builder-core</artifactId>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>14</source>
<target>14</target>
<compilerArgs>--enable-preview</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -87,6 +87,33 @@ public interface RecordBuilderMetaData {
return "stream";
}
/**
* The name to use for the nested With class
*
* @return with class name
*/
default String withClassName() {
return "With";
}
/**
* The prefix to use for the methods in the With class
*
* @return prefix
*/
default String withClassMethodPrefix() {
return "with";
}
/**
* The name to use for the method that returns "this" cast to the record type
*
* @return method name
*/
default String withClassGetThisMethodName() {
return "internalGetThis";
}
/**
* Return the comment to place at the top of generated files. Return null or an empty string for no comment.
*

View File

@@ -3,7 +3,7 @@
<parent>
<groupId>io.soabase.record-builder</groupId>
<artifactId>record-builder</artifactId>
<version>1.7.ea</version>
<version>1.8.ea</version>
</parent>
<modelVersion>4.0.0</modelVersion>
@@ -28,9 +28,6 @@
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<proc>none</proc>
<source>14</source>
<target>14</target>
<compilerArgs>--enable-preview</compilerArgs>
</configuration>
</plugin>
</plugins>

View File

@@ -61,6 +61,14 @@ public class ElementUtils {
return new ClassType(TypeName.get(recordComponent.asType()), recordComponent.getSimpleName().toString());
}
public static String getWithMethodName(ClassType component, String prefix) {
var name = component.name();
if (name.length() == 1) {
return prefix + name.toUpperCase();
}
return prefix + Character.toUpperCase(name.charAt(0)) + name.substring(1);
}
public static String getBuilderName(TypeElement element, RecordBuilderMetaData metaData, ClassType classType, String suffix) {
// generate the class name
var baseName = classType.name() + suffix;

View File

@@ -18,7 +18,6 @@ package io.soabase.recordbuilder.processor;
import com.squareup.javapoet.*;
import io.soabase.recordbuilder.core.RecordBuilderMetaData;
import javax.lang.model.element.Element;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.TypeElement;
import java.util.AbstractMap;
@@ -30,6 +29,7 @@ import java.util.stream.IntStream;
import java.util.stream.Stream;
import static io.soabase.recordbuilder.processor.ElementUtils.getBuilderName;
import static io.soabase.recordbuilder.processor.ElementUtils.getWithMethodName;
import static io.soabase.recordbuilder.processor.RecordBuilderProcessor.generatedRecordBuilderAnnotation;
class InternalRecordBuilderProcessor {
@@ -54,6 +54,7 @@ class InternalRecordBuilderProcessor {
.addModifiers(Modifier.PUBLIC)
.addAnnotation(generatedRecordBuilderAnnotation)
.addTypeVariables(typeVariables);
addWithNestedClass();
addDefaultConstructor();
addAllArgsConstructor();
addStaticDefaultBuilderMethod();
@@ -83,6 +84,102 @@ class InternalRecordBuilderProcessor {
return builderType;
}
private void addWithNestedClass() {
/*
Adds a nested interface that adds withers similar to:
public class MyRecordBuilder {
public interface With {
// with methods
}
}
*/
TypeSpec.Builder classBuilder = TypeSpec.interfaceBuilder(metaData.withClassName())
.addAnnotation(generatedRecordBuilderAnnotation)
.addJavadoc("Add withers to {@code $L}\n", recordClassType.name())
.addModifiers(Modifier.PUBLIC)
.addTypeVariables(typeVariables);
addWithGetThisMethod(classBuilder);
addWithBuilderMethod(classBuilder);
IntStream.range(0, recordComponents.size()).forEach(index -> add1WithMethod(classBuilder, recordComponents.get(index), index));
builder.addType(classBuilder.build());
}
private void addWithGetThisMethod(TypeSpec.Builder classBuilder) {
/*
Adds a method that returns "this" cast to the record similar to:
default MyRecord internalGetThis() {
Object obj = this;
return (MyRecord)obj;
}
*/
CodeBlock codeBlock = CodeBlock.builder()
.add("Object obj = this;\n")
.add("return ($T)obj;", recordClassType.typeName())
.build();
MethodSpec methodSpec = MethodSpec.methodBuilder(metaData.withClassGetThisMethodName())
.addAnnotation(generatedRecordBuilderAnnotation)
.addJavadoc("Cast this to the record type")
.addModifiers(Modifier.PUBLIC, Modifier.DEFAULT)
.returns(recordClassType.typeName())
.addCode(codeBlock)
.build();
classBuilder.addMethod(methodSpec);
}
private void addWithBuilderMethod(TypeSpec.Builder classBuilder) {
CodeBlock.Builder codeBlockBuilder = CodeBlock.builder()
.add("$T r = $L();\n", recordClassType.typeName(), metaData.withClassGetThisMethodName())
.add("return $L.$L(r);", builderClassType.name(), metaData.copyMethodName());
MethodSpec methodSpec = MethodSpec.methodBuilder(metaData.withClassMethodPrefix())
.addAnnotation(generatedRecordBuilderAnnotation)
.addJavadoc("Return a new record builder using the current values")
.addModifiers(Modifier.PUBLIC, Modifier.DEFAULT)
.returns(builderClassType.typeName())
.addCode(codeBlockBuilder.build())
.build();
classBuilder.addMethod(methodSpec);
}
private void add1WithMethod(TypeSpec.Builder classBuilder, ClassType component, int index) {
/*
Adds a with method for the component similar to:
default MyRecord withName(String name) {
MyRecord r = internalGetThis();
return new MyRecord(name, r.age());
}
*/
CodeBlock.Builder codeBlockBuilder = CodeBlock.builder()
.add("$T r = $L();\n", recordClassType.typeName(), metaData.withClassGetThisMethodName())
.add("return new $T(", recordClassType.typeName());
IntStream.range(0, recordComponents.size()).forEach(parameterIndex -> {
if (parameterIndex > 0) {
codeBlockBuilder.add(", ");
}
ClassType parameterComponent = recordComponents.get(parameterIndex);
if (parameterIndex == index) {
codeBlockBuilder.add(parameterComponent.name());
} else {
codeBlockBuilder.add("r.$L()", parameterComponent.name());
}
});
codeBlockBuilder.add(");");
String methodName = getWithMethodName(component, metaData.withClassMethodPrefix());
var parameterSpec = ParameterSpec.builder(component.typeName(), component.name()).build();
MethodSpec methodSpec = MethodSpec.methodBuilder(methodName)
.addAnnotation(generatedRecordBuilderAnnotation)
.addJavadoc("Return a new instance of {@code $L} with a new value for {@code $L}\n", recordClassType.name(), component.name())
.addModifiers(Modifier.PUBLIC, Modifier.DEFAULT)
.addParameter(parameterSpec)
.addCode(codeBlockBuilder.build())
.returns(recordClassType.typeName())
.build();
classBuilder.addMethod(methodSpec);
}
private void addDefaultConstructor() {
/*
Adds a default constructor similar to:

View File

@@ -15,10 +15,10 @@
*/
package io.soabase.recordbuilder.processor;
import java.util.Map;
import io.soabase.recordbuilder.core.RecordBuilderMetaData;
import java.util.Map;
public class OptionBasedRecordBuilderMetaData implements RecordBuilderMetaData {
/**
* @see #suffix()
@@ -65,27 +65,47 @@ public class OptionBasedRecordBuilderMetaData implements RecordBuilderMetaData {
*/
public static final String OPTION_PREFIX_ENCLOSING_CLASS_NAMES = "prefixEnclosingClassNames";
/**
* @see #withClassName()
*/
public static final String OPTION_WITH_CLASS_NAME = "withClassName";
/**
* @see #withClassMethodPrefix()
*/
public static final String OPTION_WITH_CLASS_METHOD_PREFIX = "withClassMethodPrefix";
/**
* @see #withClassGetThisMethodName()
*/
public static final String OPTION_WITH_CLASS_GET_THIS_METHOD_NAME = "withClassGetThisMethodName";
private final String suffix;
private final String interfaceSuffix;
private final String copyMethodName;
private final String builderMethodName;
private final String buildMethodName;
private final String componentsMethodName;
private final String withClassName;
private final String withClassMethodPrefix;
private final String withClassGetThisMethodName;
private final String fileComment;
private final String fileIndent;
private final boolean prefixEnclosingClassNames;
public OptionBasedRecordBuilderMetaData(Map<String, String> options) {
suffix = options.getOrDefault(OPTION_SUFFIX, "Builder");
interfaceSuffix = options.getOrDefault(OPTION_INTERFACE_SUFFIX, "Record");
builderMethodName = options.getOrDefault(OPTION_BUILDER_METHOD_NAME, "builder");
copyMethodName = options.getOrDefault(OPTION_COPY_METHOD_NAME, builderMethodName);
buildMethodName = options.getOrDefault(OPTION_BUILD_METHOD_NAME, "build");
componentsMethodName = options.getOrDefault(OPTION_COMPONENTS_METHOD_NAME, "stream");
fileComment = options.getOrDefault(OPTION_FILE_COMMENT,
"Auto generated by io.soabase.recordbuilder.core.RecordBuilder: https://github.com/Randgalt/record-builder");
fileIndent = options.getOrDefault(OPTION_FILE_INDENT, " ");
String prefixenclosingclassnamesopt = options.get(OPTION_PREFIX_ENCLOSING_CLASS_NAMES);
suffix = options.getOrDefault(OPTION_SUFFIX, DEFAULT.suffix());
interfaceSuffix = options.getOrDefault(OPTION_INTERFACE_SUFFIX, DEFAULT.interfaceSuffix());
builderMethodName = options.getOrDefault(OPTION_BUILDER_METHOD_NAME, DEFAULT.builderMethodName());
copyMethodName = options.getOrDefault(OPTION_COPY_METHOD_NAME, DEFAULT.copyMethodName());
buildMethodName = options.getOrDefault(OPTION_BUILD_METHOD_NAME, DEFAULT.buildMethodName());
componentsMethodName = options.getOrDefault(OPTION_COMPONENTS_METHOD_NAME, DEFAULT.componentsMethodName());
withClassName = options.getOrDefault(OPTION_WITH_CLASS_NAME, DEFAULT.withClassName());
withClassMethodPrefix = options.getOrDefault(OPTION_WITH_CLASS_METHOD_PREFIX, DEFAULT.withClassMethodPrefix());
withClassGetThisMethodName = options.getOrDefault(OPTION_WITH_CLASS_GET_THIS_METHOD_NAME, DEFAULT.withClassGetThisMethodName());
fileComment = options.getOrDefault(OPTION_FILE_COMMENT, DEFAULT.fileComment());
fileIndent = options.getOrDefault(OPTION_FILE_INDENT, DEFAULT.fileIndent());
String prefixenclosingclassnamesopt = options.getOrDefault(OPTION_PREFIX_ENCLOSING_CLASS_NAMES, String.valueOf(DEFAULT.prefixEnclosingClassNames()));
if (prefixenclosingclassnamesopt == null) {
prefixEnclosingClassNames = true;
} else {
@@ -118,6 +138,21 @@ public class OptionBasedRecordBuilderMetaData implements RecordBuilderMetaData {
return componentsMethodName;
}
@Override
public String withClassName() {
return withClassName;
}
@Override
public String withClassMethodPrefix() {
return withClassMethodPrefix;
}
@Override
public String withClassGetThisMethodName() {
return withClassGetThisMethodName;
}
@Override
public String fileComment() {
return fileComment;

View File

@@ -3,7 +3,7 @@
<parent>
<groupId>io.soabase.record-builder</groupId>
<artifactId>record-builder</artifactId>
<version>1.7.ea</version>
<version>1.8.ea</version>
</parent>
<modelVersion>4.0.0</modelVersion>
@@ -24,6 +24,11 @@
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
</plugin>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
@@ -37,9 +42,6 @@
<annotationProcessors>
<annotationProcessor>io.soabase.recordbuilder.processor.RecordBuilderProcessor</annotationProcessor>
</annotationProcessors>
<source>14</source>
<target>14</target>
<compilerArgs>--enable-preview</compilerArgs>
</configuration>
</plugin>

View File

@@ -18,5 +18,5 @@ package io.soabase.recordbuilder.test;
import io.soabase.recordbuilder.core.RecordBuilder;
@RecordBuilder
public record SimpleGenericRecord<T>(int i, T s) {
public record SimpleGenericRecord<T>(int i, T s) implements SimpleGenericRecordBuilder.With<T> {
}

View File

@@ -0,0 +1,49 @@
/**
* Copyright 2019 Jordan Zimmerman
*
* 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 org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.List;
class TestWithers {
@Test
void testWithers() {
var r1 = new SimpleGenericRecord<>(10, List.of("1", "2", "3"));
var r2 = r1.withS(List.of("4", "5"));
var r3 = r2.withI(20);
Assertions.assertEquals(10, r1.i());
Assertions.assertEquals(List.of("1", "2", "3"), r1.s());
Assertions.assertEquals(10, r2.i());
Assertions.assertEquals(List.of("4", "5"), r2.s());
Assertions.assertEquals(20, r3.i());
Assertions.assertEquals(List.of("4", "5"), r2.s());
}
@Test
void testWitherBuilder() {
var r1 = new SimpleGenericRecord<>(10, "ten");
var r2 = r1.with().i(20).s("twenty").build();
var r3 = r2.with().s("changed");
Assertions.assertEquals(10, r1.i());
Assertions.assertEquals("ten", r1.s());
Assertions.assertEquals(20, r2.i());
Assertions.assertEquals("twenty", r2.s());
Assertions.assertEquals(20, r3.i());
Assertions.assertEquals("changed", r3.s());
}
}