Skip to content

Commit 5c7ec7c

Browse files
committed
Support for Java 14 records (FasterXML#2709)
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.
1 parent 5bca846 commit 5c7ec7c

File tree

7 files changed

+283
-7
lines changed

7 files changed

+283
-7
lines changed

pom.xml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@
153153
-->
154154
<threadCount>4</threadCount>
155155
<parallel>classes</parallel>
156+
<argLine>--enable-preview</argLine>
156157
</configuration>
157158
</plugin>
158159

@@ -185,6 +186,25 @@
185186
<arg>-parameters</arg>
186187
</compilerArgs>
187188
</configuration>
189+
<executions>
190+
<execution>
191+
<id>default-testCompile</id>
192+
<phase>process-test-sources</phase>
193+
<goals>
194+
<goal>testCompile</goal>
195+
</goals>
196+
<configuration>
197+
<!-- Running tests with Java 14 for Records -->
198+
<fork>true</fork>
199+
<source>14</source>
200+
<target>14</target>
201+
<compilerArgs>
202+
<arg>-parameters</arg>
203+
<arg>--enable-preview</arg>
204+
</compilerArgs>
205+
</configuration>
206+
</execution>
207+
</executions>
188208
</plugin>
189209

190210
<plugin>

src/main/java/com/fasterxml/jackson/databind/deser/BasicDeserializerFactory.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,14 @@ protected SettableBeanProperty constructCreatorProperty(DeserializationContext c
973973
private PropertyName _findParamName(DeserializationContext ctxt,
974974
AnnotatedParameter param, AnnotationIntrospector intr)
975975
{
976+
if (param != null) {
977+
Class<?> ownerClass = param.getOwner().getType().getRawClass();
978+
if (RecordUtil.isRecord(ownerClass)) {
979+
String recordComponentName = RecordUtil.getRecordComponents(ownerClass)[param.getIndex()];
980+
return PropertyName.construct(recordComponentName);
981+
}
982+
}
983+
976984
if (param != null && intr != null) {
977985
final DeserializationConfig config = ctxt.getConfig();
978986
PropertyName name = intr.findNameForDeserialization(config, param);

src/main/java/com/fasterxml/jackson/databind/introspect/POJOPropertiesCollector.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import com.fasterxml.jackson.databind.cfg.MapperConfig;
1414
import com.fasterxml.jackson.databind.util.BeanUtil;
1515
import com.fasterxml.jackson.databind.util.ClassUtil;
16+
import com.fasterxml.jackson.databind.util.RecordUtil;
1617

1718
/**
1819
* Helper class used for aggregating information about all possible
@@ -447,6 +448,24 @@ protected void _addFields(Map<String, POJOPropertyBuilder> props)
447448
*/
448449
protected void _addCreators(Map<String, POJOPropertyBuilder> props)
449450
{
451+
// collect record's canonical constructor
452+
if (RecordUtil.isRecord(_classDef.getAnnotated())) {
453+
if (_creatorProperties == null) {
454+
_creatorProperties = new LinkedList<>();
455+
}
456+
AnnotatedConstructor constructor = RecordUtil.getCanonicalConstructor(_classDef);
457+
if (constructor != null) {
458+
String[] recordComponents = RecordUtil.getRecordComponents(_classDef.getAnnotated());
459+
for (int i = 0; i < constructor.getParameterCount(); i++) {
460+
AnnotatedParameter parameter = constructor.getParameter(i);
461+
POJOPropertyBuilder prop = _property(props, recordComponents[i]);
462+
prop.addCtor(parameter,
463+
PropertyName.construct(recordComponents[i]), false, true, false);
464+
_creatorProperties.add(prop);
465+
}
466+
}
467+
}
468+
450469
// can be null if annotation processing is disabled...
451470
if (!_useAnnotations) {
452471
return;

src/main/java/com/fasterxml/jackson/databind/util/BeanUtil.java

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
package com.fasterxml.jackson.databind.util;
22

3-
import java.util.Calendar;
4-
import java.util.Date;
5-
import java.util.GregorianCalendar;
6-
73
import com.fasterxml.jackson.annotation.JsonInclude;
84
import com.fasterxml.jackson.databind.JavaType;
95
import com.fasterxml.jackson.databind.introspect.AnnotatedMember;
106

7+
import java.util.Arrays;
8+
import java.util.Calendar;
9+
import java.util.Date;
10+
import java.util.GregorianCalendar;
11+
1112
/**
1213
* Helper class that contains functionality needed by both serialization
1314
* and deserialization side.
@@ -31,11 +32,17 @@ public static String okNameForGetter(AnnotatedMember am) {
3132

3233
public static String okNameForRegularGetter(AnnotatedMember am, String name)
3334
{
35+
if (RecordUtil.isRecord(am.getDeclaringClass()) &&
36+
Arrays.asList(RecordUtil.getRecordComponents(am.getDeclaringClass())).contains(name)) {
37+
// record getters are not prefixed
38+
return name;
39+
}
40+
3441
if (name.startsWith("get")) {
3542
/* 16-Feb-2009, tatu: To handle [JACKSON-53], need to block
3643
* CGLib-provided method "getCallbacks". Not sure of exact
3744
* safe criteria to get decent coverage without false matches;
38-
* but for now let's assume there's no reason to use any
45+
* but for now let's assume there's no reason to use any
3946
* such getter from CGLib.
4047
* But let's try this approach...
4148
*/
@@ -79,7 +86,7 @@ public static String okNameForMutator(AnnotatedMember am, String prefix)
7986
/* Value defaulting helpers
8087
/**********************************************************
8188
*/
82-
89+
8390
/**
8491
* Accessor used to find out "default value" to use for comparing values to
8592
* serialize, to determine whether to exclude value from serialization with
@@ -130,7 +137,7 @@ public static Object getDefaultValue(JavaType type)
130137

131138
/**
132139
* This method was added to address the need to weed out
133-
* CGLib-injected "getCallbacks" method.
140+
* CGLib-injected "getCallbacks" method.
134141
* At this point caller has detected a potential getter method
135142
* with name "getCallbacks" and we need to determine if it is
136143
* indeed injectect by Cglib. We do this by verifying that the
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package com.fasterxml.jackson.databind.util;
2+
3+
import com.fasterxml.jackson.databind.introspect.AnnotatedClass;
4+
import com.fasterxml.jackson.databind.introspect.AnnotatedConstructor;
5+
import com.fasterxml.jackson.databind.introspect.AnnotatedParameter;
6+
7+
import java.lang.reflect.Constructor;
8+
import java.lang.reflect.Field;
9+
import java.lang.reflect.Method;
10+
import java.lang.reflect.RecordComponent;
11+
import java.util.Arrays;
12+
13+
/**
14+
* Helper class to detect Java records without Java 14 as Jackson targets is Java 8.
15+
* <p>
16+
* See <a href="https://openjdk.java.net/jeps/359">JEP 359</a>
17+
*/
18+
public final class RecordUtil {
19+
20+
private static final String RECORD_CLASS_NAME = "java.lang.Record";
21+
private static final String RECORD_GET_RECORD_COMPONENTS = "getRecordComponents";
22+
23+
private static final String RECORD_COMPONENT_CLASS_NAME = "java.lang.reflect.RecordComponent";
24+
private static final String RECORD_COMPONENT_GET_NAME = "getName";
25+
private static final String RECORD_COMPONENT_GET_TYPE = "getType";
26+
27+
public static boolean isRecord(Class<?> aClass) {
28+
return aClass != null
29+
&& aClass.getSuperclass() != null
30+
&& aClass.getSuperclass().getName().equals(RECORD_CLASS_NAME);
31+
}
32+
33+
/**
34+
* @return Record component's names, ordering is preserved.
35+
*/
36+
public static String[] getRecordComponents(Class<?> aRecord) {
37+
if (!isRecord(aRecord)) {
38+
return new String[0];
39+
}
40+
41+
try {
42+
Method method = Class.class.getMethod(RECORD_GET_RECORD_COMPONENTS);
43+
Object[] components = (Object[]) method.invoke(aRecord);
44+
String[] names = new String[components.length];
45+
Method recordComponentGetName = Class.forName(RECORD_COMPONENT_CLASS_NAME).getMethod(RECORD_COMPONENT_GET_NAME);
46+
for (int i = 0; i < components.length; i++) {
47+
Object component = components[i];
48+
names[i] = (String) recordComponentGetName.invoke(component);
49+
}
50+
return names;
51+
} catch (Throwable e) {
52+
return new String[0];
53+
}
54+
}
55+
56+
public static AnnotatedConstructor getCanonicalConstructor(AnnotatedClass aRecord) {
57+
if (!isRecord(aRecord.getAnnotated())) {
58+
return null;
59+
}
60+
61+
Class<?>[] paramTypes = getRecordComponentTypes(aRecord.getAnnotated());
62+
for (AnnotatedConstructor constructor : aRecord.getConstructors()) {
63+
if (Arrays.equals(constructor.getAnnotated().getParameterTypes(), paramTypes)) {
64+
return constructor;
65+
}
66+
}
67+
return null;
68+
}
69+
70+
private static Class<?>[] getRecordComponentTypes(Class<?> aRecord) {
71+
try {
72+
Method method = Class.class.getMethod(RECORD_GET_RECORD_COMPONENTS);
73+
Object[] components = (Object[]) method.invoke(aRecord);
74+
Class<?>[] types = new Class[components.length];
75+
Method recordComponentGetName = Class.forName(RECORD_COMPONENT_CLASS_NAME).getMethod(RECORD_COMPONENT_GET_TYPE);
76+
for (int i = 0; i < components.length; i++) {
77+
Object component = components[i];
78+
types[i] = (Class<?>) recordComponentGetName.invoke(component);
79+
}
80+
return types;
81+
} catch (Throwable e) {
82+
return new Class[0];
83+
}
84+
}
85+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.fasterxml.jackson.databind;
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnore;
4+
import com.fasterxml.jackson.core.JsonProcessingException;
5+
import com.fasterxml.jackson.databind.json.JsonMapper;
6+
7+
import java.io.IOException;
8+
9+
public class RecordTest extends BaseMapTest {
10+
11+
private JsonMapper jsonMapper;
12+
13+
public void setUp() {
14+
jsonMapper = new JsonMapper();
15+
}
16+
17+
record SimpleRecord(int id, String name) {
18+
}
19+
20+
public void testSerializeSimpleRecord() throws JsonProcessingException {
21+
SimpleRecord record = new SimpleRecord(123, "Bob");
22+
23+
String json = jsonMapper.writeValueAsString(record);
24+
25+
assertEquals("{\"id\":123,\"name\":\"Bob\"}", json);
26+
}
27+
28+
public void testDeserializeSimpleRecord() throws IOException {
29+
SimpleRecord value = jsonMapper.readValue("{\"id\":123,\"name\":\"Bob\"}", SimpleRecord.class);
30+
31+
assertEquals(new SimpleRecord(123, "Bob"), value);
32+
}
33+
34+
public void testSerializeSimpleRecord_DisableAnnotationIntrospector() throws JsonProcessingException {
35+
SimpleRecord record = new SimpleRecord(123, "Bob");
36+
37+
JsonMapper mapper = JsonMapper.builder()
38+
.configure(MapperFeature.USE_ANNOTATIONS, false)
39+
.build();
40+
String json = mapper.writeValueAsString(record);
41+
42+
assertEquals("{\"id\":123,\"name\":\"Bob\"}", json);
43+
}
44+
45+
public void testDeserializeSimpleRecord_DisableAnnotationIntrospector() throws IOException {
46+
JsonMapper mapper = JsonMapper.builder()
47+
.configure(MapperFeature.USE_ANNOTATIONS, false)
48+
.build();
49+
SimpleRecord value = mapper.readValue("{\"id\":123,\"name\":\"Bob\"}", SimpleRecord.class);
50+
51+
assertEquals(new SimpleRecord(123, "Bob"), value);
52+
}
53+
54+
record RecordOfRecord(SimpleRecord record) {
55+
}
56+
57+
public void testSerializeRecordOfRecord() throws JsonProcessingException {
58+
RecordOfRecord record = new RecordOfRecord(new SimpleRecord(123, "Bob"));
59+
60+
String json = jsonMapper.writeValueAsString(record);
61+
62+
assertEquals("{\"record\":{\"id\":123,\"name\":\"Bob\"}}", json);
63+
}
64+
65+
record JsonIgnoreRecord(int id, @JsonIgnore String name) {
66+
}
67+
68+
public void testSerializeJsonIgnoreRecord() throws JsonProcessingException {
69+
JsonIgnoreRecord record = new JsonIgnoreRecord(123, "Bob");
70+
71+
String json = jsonMapper.writeValueAsString(record);
72+
73+
assertEquals("{\"id\":123}", json);
74+
}
75+
76+
record RecordWithConstructor(int id, String name) {
77+
public RecordWithConstructor(int id) {
78+
this(id, "name");
79+
}
80+
}
81+
82+
public void testDeserializeRecordWithConstructor() throws IOException {
83+
RecordWithConstructor value = jsonMapper.readValue("{\"id\":123,\"name\":\"Bob\"}", RecordWithConstructor.class);
84+
85+
assertEquals(new RecordWithConstructor(123, "Bob"), value);
86+
}
87+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.fasterxml.jackson.databind.util;
2+
3+
import com.fasterxml.jackson.databind.DeserializationConfig;
4+
import com.fasterxml.jackson.databind.JavaType;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import com.fasterxml.jackson.databind.SerializationConfig;
7+
import com.fasterxml.jackson.databind.introspect.AnnotatedClass;
8+
import com.fasterxml.jackson.databind.introspect.AnnotatedClassResolver;
9+
import org.junit.Test;
10+
11+
import static org.junit.Assert.*;
12+
13+
public class RecordUtilTest {
14+
15+
@Test
16+
public void isRecord() {
17+
assertTrue(RecordUtil.isRecord(SimpleRecord.class));
18+
assertFalse(RecordUtil.isRecord(String.class));
19+
}
20+
21+
@Test
22+
public void getRecordComponents() {
23+
assertArrayEquals(new String[]{"name", "id"}, RecordUtil.getRecordComponents(SimpleRecord.class));
24+
assertArrayEquals(new String[]{}, RecordUtil.getRecordComponents(String.class));
25+
}
26+
27+
record SimpleRecord(String name, int id) {
28+
public SimpleRecord(int id) {
29+
this("", id);
30+
}
31+
}
32+
33+
@Test
34+
public void getCanonicalConstructor() {
35+
DeserializationConfig config = new ObjectMapper().deserializationConfig();
36+
37+
assertNotNull(null, RecordUtil.getCanonicalConstructor(
38+
AnnotatedClassResolver.resolve(config,
39+
config.constructType(SimpleRecord.class),
40+
null
41+
)));
42+
43+
assertNull(null, RecordUtil.getCanonicalConstructor(
44+
AnnotatedClassResolver.resolve(config,
45+
config.constructType(String.class),
46+
null
47+
)));
48+
}
49+
50+
}

0 commit comments

Comments
 (0)