diff --git a/pom.xml b/pom.xml index 6ef73adbc1..8cb28b9f37 100644 --- a/pom.xml +++ b/pom.xml @@ -202,6 +202,56 @@ true + + + java14+ + + [14, + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-test-source + generate-test-sources + + add-test-source + + + + src/test-jdk14/java + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + true + + true + + ${java.vm.specification.version} + + -parameters + --enable-preview + + + + + org.apache.maven.plugins + maven-surefire-plugin + + --enable-preview + + + + + diff --git a/src/main/java/com/fasterxml/jackson/databind/deser/BasicDeserializerFactory.java b/src/main/java/com/fasterxml/jackson/databind/deser/BasicDeserializerFactory.java index 8a2ecc94d0..a133dc44d0 100644 --- a/src/main/java/com/fasterxml/jackson/databind/deser/BasicDeserializerFactory.java +++ b/src/main/java/com/fasterxml/jackson/databind/deser/BasicDeserializerFactory.java @@ -987,6 +987,14 @@ private PropertyName _findParamName(DeserializationContext ctxt, return PropertyName.construct(str); } } + + if (param != null) { + Class ownerClass = param.getOwner().getType().getRawClass(); + if (RecordUtil.isRecord(ownerClass)) { + String recordComponentName = RecordUtil.getRecordComponents(ownerClass)[param.getIndex()]; + return PropertyName.construct(recordComponentName); + } + } return null; } diff --git a/src/main/java/com/fasterxml/jackson/databind/introspect/POJOPropertiesCollector.java b/src/main/java/com/fasterxml/jackson/databind/introspect/POJOPropertiesCollector.java index 28a805dabb..6f047da4b6 100644 --- a/src/main/java/com/fasterxml/jackson/databind/introspect/POJOPropertiesCollector.java +++ b/src/main/java/com/fasterxml/jackson/databind/introspect/POJOPropertiesCollector.java @@ -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 @@ -488,7 +489,10 @@ protected void _addCreatorParam(Map props, // Also: if this occurs, there MUST be explicit annotation on creator itself JsonCreator.Mode creatorMode = _annotationIntrospector.findCreatorAnnotation(_config, param.getOwner()); - if ((creatorMode == null) || (creatorMode == JsonCreator.Mode.DISABLED)) { + // record canonical constructor does not require annotation to be used + boolean isRecordCanonicalConstructor = RecordUtil.getCanonicalConstructor(_classDef) == param.getOwner(); + if (!isRecordCanonicalConstructor + && (creatorMode == null) || (creatorMode == JsonCreator.Mode.DISABLED)) { return; } pn = PropertyName.construct(impl); diff --git a/src/main/java/com/fasterxml/jackson/databind/util/BeanUtil.java b/src/main/java/com/fasterxml/jackson/databind/util/BeanUtil.java index 250f89a376..ea769ae13c 100644 --- a/src/main/java/com/fasterxml/jackson/databind/util/BeanUtil.java +++ b/src/main/java/com/fasterxml/jackson/databind/util/BeanUtil.java @@ -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; @@ -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. @@ -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... */ @@ -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 @@ -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 diff --git a/src/main/java/com/fasterxml/jackson/databind/util/RecordUtil.java b/src/main/java/com/fasterxml/jackson/databind/util/RecordUtil.java new file mode 100644 index 0000000000..90309a8268 --- /dev/null +++ b/src/main/java/com/fasterxml/jackson/databind/util/RecordUtil.java @@ -0,0 +1,103 @@ +package com.fasterxml.jackson.databind.util; + +import com.fasterxml.jackson.databind.introspect.AnnotatedClass; +import com.fasterxml.jackson.databind.introspect.AnnotatedConstructor; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; + +/** + * Helper class to detect Java records without Java 14 as Jackson targets is Java 8. + *

+ * See JEP 359 + */ +public final class RecordUtil { + + private static final Method IS_RECORD; + private static final Method GET_RECORD_COMPONENTS; + private static final Method GET_NAME; + private static final Method GET_TYPE; + + static { + Method isRecord; + Method getRecordComponents; + Method getName; + Method getType; + try { + isRecord = Class.class.getDeclaredMethod("isRecord"); + getRecordComponents = Class.class.getMethod("getRecordComponents"); + Class c = Class.forName("java.lang.reflect.RecordComponent"); + getName = c.getMethod("getName"); + getType = c.getMethod("getType"); + } catch (ClassNotFoundException| NoSuchMethodException e) { + // pre-Java-14 + isRecord = null; + getRecordComponents = null; + getName = null; + getType = null; + } + IS_RECORD = isRecord; + GET_RECORD_COMPONENTS = getRecordComponents; + GET_NAME = getName; + GET_TYPE = getType; + } + + public static boolean isRecord(Class aClass) { + try { + return IS_RECORD == null ? false : (boolean) IS_RECORD.invoke(aClass); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new AssertionError(); + } + } + + /** + * @return Record component's names, ordering is preserved. + */ + public static String[] getRecordComponents(Class aRecord) { + if (!isRecord(aRecord)) { + return new String[0]; + } + + try { + Object[] components = (Object[]) GET_RECORD_COMPONENTS.invoke(aRecord); + String[] names = new String[components.length]; + for (int i = 0; i < components.length; i++) { + Object component = components[i]; + names[i] = (String) GET_NAME.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 { + Object[] components = (Object[]) GET_RECORD_COMPONENTS.invoke(aRecord); + Class[] types = new Class[components.length]; + for (int i = 0; i < components.length; i++) { + Object component = components[i]; + types[i] = (Class) GET_TYPE.invoke(component); + } + return types; + } catch (Throwable e) { + return new Class[0]; + } + } +} + diff --git a/src/test-jdk14/java/com/fasterxml/jackson/databind/RecordTest.java b/src/test-jdk14/java/com/fasterxml/jackson/databind/RecordTest.java new file mode 100644 index 0000000000..ce440cee36 --- /dev/null +++ b/src/test-jdk14/java/com/fasterxml/jackson/databind/RecordTest.java @@ -0,0 +1,105 @@ +package com.fasterxml.jackson.databind; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +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(); + } + + public 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); + } + + public 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); + } + + public 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); + } + + public 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); + } + + public record JsonPropertyRenameRecord(int id, @JsonProperty("rename")String name) { + } + + public void testSerializeJsonRenameRecord() throws JsonProcessingException { + JsonPropertyRenameRecord record = new JsonPropertyRenameRecord(123, "Bob"); + + String json = jsonMapper.writeValueAsString(record); + + assertEquals("{\"id\":123,\"rename\":\"Bob\"}", json); + } + + public void testDeserializeJsonRenameRecord() throws IOException { + JsonPropertyRenameRecord value = jsonMapper.readValue("{\"id\":123,\"rename\":\"Bob\"}", JsonPropertyRenameRecord.class); + + assertEquals(new JsonPropertyRenameRecord(123, "Bob"), value); + } +} diff --git a/src/test-jdk14/java/com/fasterxml/jackson/databind/util/RecordUtilTest.java b/src/test-jdk14/java/com/fasterxml/jackson/databind/util/RecordUtilTest.java new file mode 100644 index 0000000000..d9cbe0580f --- /dev/null +++ b/src/test-jdk14/java/com/fasterxml/jackson/databind/util/RecordUtilTest.java @@ -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)); + } + + public 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 + ))); + } + +}