Skip to content

Commit 977fbb3

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 977fbb3

File tree

7 files changed

+280
-3
lines changed

7 files changed

+280
-3
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: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.fasterxml.jackson.databind.util;
22

3+
import java.util.Arrays;
34
import java.util.Calendar;
45
import java.util.Date;
56
import java.util.GregorianCalendar;
@@ -8,6 +9,7 @@
89
import com.fasterxml.jackson.databind.JavaType;
910
import com.fasterxml.jackson.databind.introspect.AnnotatedMember;
1011

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

3234
public static String okNameForRegularGetter(AnnotatedMember am, String name)
3335
{
36+
if (RecordUtil.isRecord(am.getDeclaringClass()) &&
37+
Arrays.asList(RecordUtil.getRecordComponents(am.getDeclaringClass())).contains(name)) {
38+
// record getters are not prefixed
39+
return name;
40+
}
41+
3442
if (name.startsWith("get")) {
3543
/* 16-Feb-2009, tatu: To handle [JACKSON-53], need to block
3644
* CGLib-provided method "getCallbacks". Not sure of exact
3745
* safe criteria to get decent coverage without false matches;
38-
* but for now let's assume there's no reason to use any
46+
* but for now let's assume there's no reason to use any
3947
* such getter from CGLib.
4048
* But let's try this approach...
4149
*/
@@ -79,7 +87,7 @@ public static String okNameForMutator(AnnotatedMember am, String prefix)
7987
/* Value defaulting helpers
8088
/**********************************************************
8189
*/
82-
90+
8391
/**
8492
* Accessor used to find out "default value" to use for comparing values to
8593
* serialize, to determine whether to exclude value from serialization with
@@ -130,7 +138,7 @@ public static Object getDefaultValue(JavaType type)
130138

131139
/**
132140
* This method was added to address the need to weed out
133-
* CGLib-injected "getCallbacks" method.
141+
* CGLib-injected "getCallbacks" method.
134142
* At this point caller has detected a potential getter method
135143
* with name "getCallbacks" and we need to determine if it is
136144
* 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)