Skip to content

Commit 072189b

Browse files
committed
Support for Java 14 records (#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. A new Maven profile has been introduced to build JDK14 tests using records, those tests have their own source folder, this way it is still possible to build with JDK < 14. 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 072189b

File tree

7 files changed

+307
-3
lines changed

7 files changed

+307
-3
lines changed

pom.xml

+51
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,57 @@
202202
<skipTests>true</skipTests>
203203
</properties>
204204
</profile>
205+
<profile>
206+
<!-- Build Record tests using Java 14 if JDK is available -->
207+
<id>java14</id>
208+
<activation>
209+
<jdk>14</jdk>
210+
</activation>
211+
<build>
212+
<plugins>
213+
<plugin>
214+
<groupId>org.codehaus.mojo</groupId>
215+
<artifactId>build-helper-maven-plugin</artifactId>
216+
<executions>
217+
<execution>
218+
<id>add-test-source</id>
219+
<phase>generate-test-sources</phase>
220+
<goals>
221+
<goal>add-test-source</goal>
222+
</goals>
223+
<configuration>
224+
<sources>
225+
<source>src/test-jdk14/java</source>
226+
</sources>
227+
</configuration>
228+
</execution>
229+
</executions>
230+
</plugin>
231+
<plugin>
232+
<groupId>org.apache.maven.plugins</groupId>
233+
<artifactId>maven-compiler-plugin</artifactId>
234+
<inherited>true</inherited>
235+
<configuration>
236+
<optimize>true</optimize>
237+
<!-- Enable Java 14 for all sources so that Intellij picks the right language level -->
238+
<source>14</source>
239+
<target>14</target>
240+
<compilerArgs>
241+
<arg>-parameters</arg>
242+
<arg>--enable-preview</arg>
243+
</compilerArgs>
244+
</configuration>
245+
</plugin>
246+
<plugin>
247+
<groupId>org.apache.maven.plugins</groupId>
248+
<artifactId>maven-surefire-plugin</artifactId>
249+
<configuration>
250+
<argLine>--enable-preview</argLine>
251+
</configuration>
252+
</plugin>
253+
</plugins>
254+
</build>
255+
</profile>
205256
</profiles>
206257

207258
</project>

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

+8
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

+19
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

+11-3
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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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+
6+
import java.lang.reflect.Method;
7+
import java.util.Arrays;
8+
9+
/**
10+
* Helper class to detect Java records without Java 14 as Jackson targets is Java 8.
11+
* <p>
12+
* See <a href="https://openjdk.java.net/jeps/359">JEP 359</a>
13+
*/
14+
public final class RecordUtil {
15+
16+
private static final String RECORD_CLASS_NAME = "java.lang.Record";
17+
private static final String RECORD_GET_RECORD_COMPONENTS = "getRecordComponents";
18+
19+
private static final String RECORD_COMPONENT_CLASS_NAME = "java.lang.reflect.RecordComponent";
20+
private static final String RECORD_COMPONENT_GET_NAME = "getName";
21+
private static final String RECORD_COMPONENT_GET_TYPE = "getType";
22+
23+
public static boolean isRecord(Class<?> aClass) {
24+
return aClass != null
25+
&& aClass.getSuperclass() != null
26+
&& aClass.getSuperclass().getName().equals(RECORD_CLASS_NAME);
27+
}
28+
29+
/**
30+
* @return Record component's names, ordering is preserved.
31+
*/
32+
public static String[] getRecordComponents(Class<?> aRecord) {
33+
if (!isRecord(aRecord)) {
34+
return new String[0];
35+
}
36+
37+
try {
38+
Method method = Class.class.getMethod(RECORD_GET_RECORD_COMPONENTS);
39+
Object[] components = (Object[]) method.invoke(aRecord);
40+
String[] names = new String[components.length];
41+
Method recordComponentGetName = Class.forName(RECORD_COMPONENT_CLASS_NAME).getMethod(RECORD_COMPONENT_GET_NAME);
42+
for (int i = 0; i < components.length; i++) {
43+
Object component = components[i];
44+
names[i] = (String) recordComponentGetName.invoke(component);
45+
}
46+
return names;
47+
} catch (Throwable e) {
48+
return new String[0];
49+
}
50+
}
51+
52+
public static AnnotatedConstructor getCanonicalConstructor(AnnotatedClass aRecord) {
53+
if (!isRecord(aRecord.getAnnotated())) {
54+
return null;
55+
}
56+
57+
Class<?>[] paramTypes = getRecordComponentTypes(aRecord.getAnnotated());
58+
for (AnnotatedConstructor constructor : aRecord.getConstructors()) {
59+
if (Arrays.equals(constructor.getAnnotated().getParameterTypes(), paramTypes)) {
60+
return constructor;
61+
}
62+
}
63+
return null;
64+
}
65+
66+
private static Class<?>[] getRecordComponentTypes(Class<?> aRecord) {
67+
try {
68+
Method method = Class.class.getMethod(RECORD_GET_RECORD_COMPONENTS);
69+
Object[] components = (Object[]) method.invoke(aRecord);
70+
Class<?>[] types = new Class[components.length];
71+
Method recordComponentGetName = Class.forName(RECORD_COMPONENT_CLASS_NAME).getMethod(RECORD_COMPONENT_GET_TYPE);
72+
for (int i = 0; i < components.length; i++) {
73+
Object component = components[i];
74+
types[i] = (Class<?>) recordComponentGetName.invoke(component);
75+
}
76+
return types;
77+
} catch (Throwable e) {
78+
return new Class[0];
79+
}
80+
}
81+
}
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+
}
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)