Skip to content

Commit

Permalink
Support for Java 14 records (FasterXML#2709)
Browse files Browse the repository at this point in the history
First attempt at supporting Java 14 records (JEP 359).

Records are simple DTO/POJO objects with final fields (components) and accessors.
Record's components are automatically serialized and the canonical constructor is
used for deserialization.

Implementation is still compatible with Java 8 and uses a bit of reflection to
access record's components. However the unit tests now require a JDK 14 to run.

The basic idea is to make record's components discovered as properties (similar to
beans having getters) and to make the canonical constructor accessible via implicit
parameter names.
  • Loading branch information
youribonnaffe committed May 9, 2020
1 parent 5bca846 commit 977fbb3
Show file tree
Hide file tree
Showing 7 changed files with 280 additions and 3 deletions.
20 changes: 20 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@
-->
<threadCount>4</threadCount>
<parallel>classes</parallel>
<argLine>--enable-preview</argLine>
</configuration>
</plugin>

Expand Down Expand Up @@ -185,6 +186,25 @@
<arg>-parameters</arg>
</compilerArgs>
</configuration>
<executions>
<execution>
<id>default-testCompile</id>
<phase>process-test-sources</phase>
<goals>
<goal>testCompile</goal>
</goals>
<configuration>
<!-- Running tests with Java 14 for Records -->
<fork>true</fork>
<source>14</source>
<target>14</target>
<compilerArgs>
<arg>-parameters</arg>
<arg>--enable-preview</arg>
</compilerArgs>
</configuration>
</execution>
</executions>
</plugin>

<plugin>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,14 @@ protected SettableBeanProperty constructCreatorProperty(DeserializationContext c
private PropertyName _findParamName(DeserializationContext ctxt,
AnnotatedParameter param, AnnotationIntrospector intr)
{
if (param != null) {
Class<?> ownerClass = param.getOwner().getType().getRawClass();
if (RecordUtil.isRecord(ownerClass)) {
String recordComponentName = RecordUtil.getRecordComponents(ownerClass)[param.getIndex()];
return PropertyName.construct(recordComponentName);
}
}

if (param != null && intr != null) {
final DeserializationConfig config = ctxt.getConfig();
PropertyName name = intr.findNameForDeserialization(config, param);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import com.fasterxml.jackson.databind.cfg.MapperConfig;
import com.fasterxml.jackson.databind.util.BeanUtil;
import com.fasterxml.jackson.databind.util.ClassUtil;
import com.fasterxml.jackson.databind.util.RecordUtil;

/**
* Helper class used for aggregating information about all possible
Expand Down Expand Up @@ -447,6 +448,24 @@ protected void _addFields(Map<String, POJOPropertyBuilder> props)
*/
protected void _addCreators(Map<String, POJOPropertyBuilder> props)
{
// collect record's canonical constructor
if (RecordUtil.isRecord(_classDef.getAnnotated())) {
if (_creatorProperties == null) {
_creatorProperties = new LinkedList<>();
}
AnnotatedConstructor constructor = RecordUtil.getCanonicalConstructor(_classDef);
if (constructor != null) {
String[] recordComponents = RecordUtil.getRecordComponents(_classDef.getAnnotated());
for (int i = 0; i < constructor.getParameterCount(); i++) {
AnnotatedParameter parameter = constructor.getParameter(i);
POJOPropertyBuilder prop = _property(props, recordComponents[i]);
prop.addCtor(parameter,
PropertyName.construct(recordComponents[i]), false, true, false);
_creatorProperties.add(prop);
}
}
}

// can be null if annotation processing is disabled...
if (!_useAnnotations) {
return;
Expand Down
14 changes: 11 additions & 3 deletions src/main/java/com/fasterxml/jackson/databind/util/BeanUtil.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.fasterxml.jackson.databind.util;

import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
Expand All @@ -8,6 +9,7 @@
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.introspect.AnnotatedMember;


/**
* Helper class that contains functionality needed by both serialization
* and deserialization side.
Expand All @@ -31,11 +33,17 @@ public static String okNameForGetter(AnnotatedMember am) {

public static String okNameForRegularGetter(AnnotatedMember am, String name)
{
if (RecordUtil.isRecord(am.getDeclaringClass()) &&
Arrays.asList(RecordUtil.getRecordComponents(am.getDeclaringClass())).contains(name)) {
// record getters are not prefixed
return name;
}

if (name.startsWith("get")) {
/* 16-Feb-2009, tatu: To handle [JACKSON-53], need to block
* CGLib-provided method "getCallbacks". Not sure of exact
* safe criteria to get decent coverage without false matches;
* but for now let's assume there's no reason to use any
* but for now let's assume there's no reason to use any
* such getter from CGLib.
* But let's try this approach...
*/
Expand Down Expand Up @@ -79,7 +87,7 @@ public static String okNameForMutator(AnnotatedMember am, String prefix)
/* Value defaulting helpers
/**********************************************************
*/

/**
* Accessor used to find out "default value" to use for comparing values to
* serialize, to determine whether to exclude value from serialization with
Expand Down Expand Up @@ -130,7 +138,7 @@ public static Object getDefaultValue(JavaType type)

/**
* This method was added to address the need to weed out
* CGLib-injected "getCallbacks" method.
* CGLib-injected "getCallbacks" method.
* At this point caller has detected a potential getter method
* with name "getCallbacks" and we need to determine if it is
* indeed injectect by Cglib. We do this by verifying that the
Expand Down
85 changes: 85 additions & 0 deletions src/main/java/com/fasterxml/jackson/databind/util/RecordUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.fasterxml.jackson.databind.util;

import com.fasterxml.jackson.databind.introspect.AnnotatedClass;
import com.fasterxml.jackson.databind.introspect.AnnotatedConstructor;
import com.fasterxml.jackson.databind.introspect.AnnotatedParameter;

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.RecordComponent;
import java.util.Arrays;

/**
* Helper class to detect Java records without Java 14 as Jackson targets is Java 8.
* <p>
* See <a href="https://openjdk.java.net/jeps/359">JEP 359</a>
*/
public final class RecordUtil {

private static final String RECORD_CLASS_NAME = "java.lang.Record";
private static final String RECORD_GET_RECORD_COMPONENTS = "getRecordComponents";

private static final String RECORD_COMPONENT_CLASS_NAME = "java.lang.reflect.RecordComponent";
private static final String RECORD_COMPONENT_GET_NAME = "getName";
private static final String RECORD_COMPONENT_GET_TYPE = "getType";

public static boolean isRecord(Class<?> aClass) {
return aClass != null
&& aClass.getSuperclass() != null
&& aClass.getSuperclass().getName().equals(RECORD_CLASS_NAME);
}

/**
* @return Record component's names, ordering is preserved.
*/
public static String[] getRecordComponents(Class<?> aRecord) {
if (!isRecord(aRecord)) {
return new String[0];
}

try {
Method method = Class.class.getMethod(RECORD_GET_RECORD_COMPONENTS);
Object[] components = (Object[]) method.invoke(aRecord);
String[] names = new String[components.length];
Method recordComponentGetName = Class.forName(RECORD_COMPONENT_CLASS_NAME).getMethod(RECORD_COMPONENT_GET_NAME);
for (int i = 0; i < components.length; i++) {
Object component = components[i];
names[i] = (String) recordComponentGetName.invoke(component);
}
return names;
} catch (Throwable e) {
return new String[0];
}
}

public static AnnotatedConstructor getCanonicalConstructor(AnnotatedClass aRecord) {
if (!isRecord(aRecord.getAnnotated())) {
return null;
}

Class<?>[] paramTypes = getRecordComponentTypes(aRecord.getAnnotated());
for (AnnotatedConstructor constructor : aRecord.getConstructors()) {
if (Arrays.equals(constructor.getAnnotated().getParameterTypes(), paramTypes)) {
return constructor;
}
}
return null;
}

private static Class<?>[] getRecordComponentTypes(Class<?> aRecord) {
try {
Method method = Class.class.getMethod(RECORD_GET_RECORD_COMPONENTS);
Object[] components = (Object[]) method.invoke(aRecord);
Class<?>[] types = new Class[components.length];
Method recordComponentGetName = Class.forName(RECORD_COMPONENT_CLASS_NAME).getMethod(RECORD_COMPONENT_GET_TYPE);
for (int i = 0; i < components.length; i++) {
Object component = components[i];
types[i] = (Class<?>) recordComponentGetName.invoke(component);
}
return types;
} catch (Throwable e) {
return new Class[0];
}
}
}
87 changes: 87 additions & 0 deletions src/test/java/com/fasterxml/jackson/databind/RecordTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.fasterxml.jackson.databind;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.json.JsonMapper;

import java.io.IOException;

public class RecordTest extends BaseMapTest {

private JsonMapper jsonMapper;

public void setUp() {
jsonMapper = new JsonMapper();
}

record SimpleRecord(int id, String name) {
}

public void testSerializeSimpleRecord() throws JsonProcessingException {
SimpleRecord record = new SimpleRecord(123, "Bob");

String json = jsonMapper.writeValueAsString(record);

assertEquals("{\"id\":123,\"name\":\"Bob\"}", json);
}

public void testDeserializeSimpleRecord() throws IOException {
SimpleRecord value = jsonMapper.readValue("{\"id\":123,\"name\":\"Bob\"}", SimpleRecord.class);

assertEquals(new SimpleRecord(123, "Bob"), value);
}

public void testSerializeSimpleRecord_DisableAnnotationIntrospector() throws JsonProcessingException {
SimpleRecord record = new SimpleRecord(123, "Bob");

JsonMapper mapper = JsonMapper.builder()
.configure(MapperFeature.USE_ANNOTATIONS, false)
.build();
String json = mapper.writeValueAsString(record);

assertEquals("{\"id\":123,\"name\":\"Bob\"}", json);
}

public void testDeserializeSimpleRecord_DisableAnnotationIntrospector() throws IOException {
JsonMapper mapper = JsonMapper.builder()
.configure(MapperFeature.USE_ANNOTATIONS, false)
.build();
SimpleRecord value = mapper.readValue("{\"id\":123,\"name\":\"Bob\"}", SimpleRecord.class);

assertEquals(new SimpleRecord(123, "Bob"), value);
}

record RecordOfRecord(SimpleRecord record) {
}

public void testSerializeRecordOfRecord() throws JsonProcessingException {
RecordOfRecord record = new RecordOfRecord(new SimpleRecord(123, "Bob"));

String json = jsonMapper.writeValueAsString(record);

assertEquals("{\"record\":{\"id\":123,\"name\":\"Bob\"}}", json);
}

record JsonIgnoreRecord(int id, @JsonIgnore String name) {
}

public void testSerializeJsonIgnoreRecord() throws JsonProcessingException {
JsonIgnoreRecord record = new JsonIgnoreRecord(123, "Bob");

String json = jsonMapper.writeValueAsString(record);

assertEquals("{\"id\":123}", json);
}

record RecordWithConstructor(int id, String name) {
public RecordWithConstructor(int id) {
this(id, "name");
}
}

public void testDeserializeRecordWithConstructor() throws IOException {
RecordWithConstructor value = jsonMapper.readValue("{\"id\":123,\"name\":\"Bob\"}", RecordWithConstructor.class);

assertEquals(new RecordWithConstructor(123, "Bob"), value);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.fasterxml.jackson.databind.util;

import com.fasterxml.jackson.databind.DeserializationConfig;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationConfig;
import com.fasterxml.jackson.databind.introspect.AnnotatedClass;
import com.fasterxml.jackson.databind.introspect.AnnotatedClassResolver;
import org.junit.Test;

import static org.junit.Assert.*;

public class RecordUtilTest {

@Test
public void isRecord() {
assertTrue(RecordUtil.isRecord(SimpleRecord.class));
assertFalse(RecordUtil.isRecord(String.class));
}

@Test
public void getRecordComponents() {
assertArrayEquals(new String[]{"name", "id"}, RecordUtil.getRecordComponents(SimpleRecord.class));
assertArrayEquals(new String[]{}, RecordUtil.getRecordComponents(String.class));
}

record SimpleRecord(String name, int id) {
public SimpleRecord(int id) {
this("", id);
}
}

@Test
public void getCanonicalConstructor() {
DeserializationConfig config = new ObjectMapper().deserializationConfig();

assertNotNull(null, RecordUtil.getCanonicalConstructor(
AnnotatedClassResolver.resolve(config,
config.constructType(SimpleRecord.class),
null
)));

assertNull(null, RecordUtil.getCanonicalConstructor(
AnnotatedClassResolver.resolve(config,
config.constructType(String.class),
null
)));
}

}

0 comments on commit 977fbb3

Please sign in to comment.