diff --git a/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/BeanConstructors.java b/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/BeanConstructors.java index 0cf07735..88700d59 100644 --- a/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/BeanConstructors.java +++ b/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/BeanConstructors.java @@ -14,6 +14,13 @@ public class BeanConstructors protected Constructor _noArgsCtor; + /** + * Constructor (canonical) used when deserializing Java Record types. + * + * @since 2.18 + */ + protected Constructor _recordCtor; + protected Constructor _intCtor; protected Constructor _longCtor; protected Constructor _stringCtor; @@ -27,6 +34,14 @@ public BeanConstructors addNoArgsConstructor(Constructor ctor) { return this; } + /** + * @since 2.18 + */ + public BeanConstructors addRecordConstructor(Constructor ctor) { + _recordCtor = ctor; + return this; + } + public BeanConstructors addIntConstructor(Constructor ctor) { _intCtor = ctor; return this; @@ -46,6 +61,9 @@ public void forceAccess() { if (_noArgsCtor != null) { _noArgsCtor.setAccessible(true); } + if (_recordCtor != null) { + _recordCtor.setAccessible(true); + } if (_intCtor != null) { _intCtor.setAccessible(true); } @@ -64,6 +82,16 @@ protected Object create() throws Exception { return _noArgsCtor.newInstance((Object[]) null); } + /** + * @since 2.18 + */ + protected Object createRecord(Object[] components) throws Exception { + if (_recordCtor == null) { + throw new IllegalStateException("Class "+_valueType.getName()+" does not have record constructor to use"); + } + return _recordCtor.newInstance(components); + } + protected Object create(String str) throws Exception { if (_stringCtor == null) { throw new IllegalStateException("Class "+_valueType.getName()+" does not have single-String constructor to use"); diff --git a/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/BeanPropertyIntrospector.java b/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/BeanPropertyIntrospector.java index 34e439a4..889d008f 100644 --- a/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/BeanPropertyIntrospector.java +++ b/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/BeanPropertyIntrospector.java @@ -55,18 +55,27 @@ private POJODefinition _introspectDefinition(Class beanType, constructors = null; } else { constructors = new BeanConstructors(beanType); - for (Constructor ctor : beanType.getDeclaredConstructors()) { - Class[] argTypes = ctor.getParameterTypes(); - if (argTypes.length == 0) { - constructors.addNoArgsConstructor(ctor); - } else if (argTypes.length == 1) { - Class argType = argTypes[0]; - if (argType == String.class) { - constructors.addStringConstructor(ctor); - } else if (argType == Integer.class || argType == Integer.TYPE) { - constructors.addIntConstructor(ctor); - } else if (argType == Long.class || argType == Long.TYPE) { - constructors.addLongConstructor(ctor); + if (RecordsHelpers.isRecordType(beanType)) { + Constructor canonical = RecordsHelpers.findCanonicalConstructor(beanType); + if (canonical == null) { // should never happen + throw new IllegalArgumentException( +"Unable to find canonical constructor of Record type `"+beanType.getClass().getName()+"`"); + } + constructors.addRecordConstructor(canonical); + } else { + for (Constructor ctor : beanType.getDeclaredConstructors()) { + Class[] argTypes = ctor.getParameterTypes(); + if (argTypes.length == 0) { + constructors.addNoArgsConstructor(ctor); + } else if (argTypes.length == 1) { + Class argType = argTypes[0]; + if (argType == String.class) { + constructors.addStringConstructor(ctor); + } else if (argType == Integer.class || argType == Integer.TYPE) { + constructors.addIntConstructor(ctor); + } else if (argType == Long.class || argType == Long.TYPE) { + constructors.addLongConstructor(ctor); + } } } } @@ -152,7 +161,7 @@ private static void _introspect(Class currType, Map prop name = decap(name.substring(2)); _propFrom(props, name).withIsGetter(m); } - } else if (isFieldNameGettersEnabled){ + } else if (isFieldNameGettersEnabled) { // 10-Mar-2024: [jackson-jr#94]: // This will allow getters with field name as their getters, // like the ones generated by Groovy (or JDK 17 for Records). @@ -200,8 +209,10 @@ private static String decap(String name) { /** * Helper method to detect Groovy's problematic metadata accessor type. - * Groovy MetaClass have cyclic reference, and hence the class containing it should not be - * serialized without either removing that reference, or skipping over such references. + *

+ * NOTE: Groovy MetaClass have cyclic reference, and hence the class containing + * it should not be serialized without either removing that reference, + * or skipping over such references. */ protected static boolean isGroovyMetaClass(Class clazz) { return "groovy.lang.MetaClass".equals(clazz.getName()); diff --git a/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/BeanPropertyReader.java b/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/BeanPropertyReader.java index 016e3cbe..e153728d 100644 --- a/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/BeanPropertyReader.java +++ b/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/BeanPropertyReader.java @@ -31,7 +31,17 @@ public final class BeanPropertyReader */ private final Field _field; - public BeanPropertyReader(String name, Field f, Method setter) { + /** + * Index used for {@code Record}s constructor parameters. It is not used for getter/setter methods. + * + * @since 2.18 + */ + private final int _index; + + /** + * @since 2.18 + */ + public BeanPropertyReader(String name, Field f, Method setter, int propertyIndex) { if ((f == null) && (setter == null)) { throw new IllegalArgumentException("Both `field` and `setter` can not be null"); } @@ -39,12 +49,19 @@ public BeanPropertyReader(String name, Field f, Method setter) { _field = f; _setter = setter; _valueReader = null; + _index = propertyIndex; + } + + @Deprecated // @since 2.18 + public BeanPropertyReader(String name, Field f, Method setter) { + this(name, f, setter, -1); } protected BeanPropertyReader(BeanPropertyReader src, ValueReader vr) { _name = src._name; _field = src._field; _setter = src._setter; + _index = src._index; _valueReader = vr; } @@ -69,6 +86,13 @@ public Class rawSetterType() { public ValueReader getReader() { return _valueReader; } public String getName() { return _name; } + /** + * @since 2.18 + */ + public int getIndex() { + return _index; + } + public void setValueFor(Object bean, Object[] valueBuf) throws IOException { diff --git a/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/BeanReader.java b/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/BeanReader.java index c07974b3..f93eae0f 100644 --- a/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/BeanReader.java +++ b/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/BeanReader.java @@ -36,6 +36,8 @@ public class BeanReader */ protected final BeanConstructors _constructors; + protected final boolean _isRecordType; + /** * Constructors used for deserialization use case * @@ -56,6 +58,7 @@ public BeanReader(Class type, Map props, aliasMapping = Collections.emptyMap(); } _aliasMapping = aliasMapping; + _isRecordType = RecordsHelpers.isRecordType(type); } @Deprecated // since 2.17 @@ -69,12 +72,6 @@ public BeanReader(Class type, Map props, ignorableNames, aliasMapping); } - @Deprecated // since 2.11 - public BeanReader(Class type, Map props, - Constructor defaultCtor, Constructor stringCtor, Constructor longCtor) { - this(type, props, defaultCtor, stringCtor, longCtor, null, null); - } - public Map propertiesByName() { return _propsByName; } public BeanPropertyReader findProperty(String name) { @@ -104,6 +101,9 @@ public Object readNext(JSONReader r, JsonParser p) throws IOException return _constructors.create(p.getLongValue()); case START_OBJECT: { + if (_isRecordType) { + return readRecord(r, p); + } Object bean = _constructors.create(); final Object[] valueBuf = r._setterBuffer; String propName; @@ -120,7 +120,7 @@ public Object readNext(JSONReader r, JsonParser p) throws IOException // also verify we are not confused... if (!p.hasToken(JsonToken.END_OBJECT)) { throw _reportProblem(p); - } + } return bean; } default: @@ -135,7 +135,23 @@ public Object readNext(JSONReader r, JsonParser p) throws IOException throw JSONObjectException.from(p, "Can not create a "+_valueType.getName()+" instance out of "+_tokenDesc(p)); } - + + private Object readRecord(JSONReader r, JsonParser p) throws Exception { + final Object[] values = new Object[propertiesByName().size()]; + + String propName; + for (; (propName = p.nextFieldName()) != null;) { + BeanPropertyReader prop = findProperty(propName); + if (prop == null) { + handleUnknown(r, p, propName); + continue; + } + Object value = prop.getReader().readNext(r, p); + values[prop.getIndex()] = value; + } + return _constructors.createRecord(values); + } + /** * Method used for deserialization; will read an instance of the bean * type using given parser. @@ -155,6 +171,9 @@ public Object read(JSONReader r, JsonParser p) throws IOException return _constructors.create(p.getLongValue()); case START_OBJECT: { + if (_isRecordType) { + return readRecord(r, p); + } Object bean = _constructors.create(); String propName; final Object[] valueBuf = r._setterBuffer; diff --git a/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/RecordsHelpers.java b/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/RecordsHelpers.java index 939fd277..e18309fd 100644 --- a/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/RecordsHelpers.java +++ b/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/RecordsHelpers.java @@ -1,15 +1,14 @@ package com.fasterxml.jackson.jr.ob.impl; -import com.fasterxml.jackson.jr.ob.impl.POJODefinition.PropBuilder; - import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.util.Arrays; import java.util.Map; +import com.fasterxml.jackson.jr.ob.impl.POJODefinition.PropBuilder; + /** - * Helper class to get Java Record metadata, from Java 8 (not using - * JDK 17 methods) + * Helper class to get Java Record metadata. * * @since 2.18 */ @@ -17,29 +16,48 @@ public final class RecordsHelpers { private static boolean supportsRecords; private static Method getRecordComponentsMethod; - private static Method getTypeMethod; + private static Method getComponentTypeMethod; - static { - Method getRecordComponentsMethod; - Method getTypeMethod; + // We may need this in future: + //private static Method getComponentNameMethod; + static { try { getRecordComponentsMethod = Class.class.getMethod("getRecordComponents"); Class recordComponentClass = Class.forName("java.lang.reflect.RecordComponent"); - getTypeMethod = recordComponentClass.getMethod("getType"); + getComponentTypeMethod = recordComponentClass.getMethod("getType"); + //getComponentNameMethod = recordComponentClass.getMethod("getName"); supportsRecords = true; } catch (Throwable t) { - getRecordComponentsMethod = null; - getTypeMethod = null; supportsRecords = false; } - - RecordsHelpers.getRecordComponentsMethod = getRecordComponentsMethod; - RecordsHelpers.getTypeMethod = getTypeMethod; } private RecordsHelpers() {} - static boolean isRecordConstructor(Class beanClass, Constructor ctor, Map propsByName) { + static Constructor findCanonicalConstructor(Class beanClass) { + // sanity check: caller shouldn't rely on it + if (!supportsRecords || !isRecordType(beanClass)) { + return null; + } + try { + final Class[] componentTypes = componentTypes(beanClass); + for (Constructor ctor : beanClass.getDeclaredConstructors()) { + final Class[] parameterTypes = ctor.getParameterTypes(); + if (parameterTypes.length == componentTypes.length) { + if (Arrays.equals(parameterTypes, componentTypes)) { + return ctor; + } + } + } + } catch (ReflectiveOperationException e) { + ; + } + return null; + } + + static boolean isRecordConstructor(Class beanClass, Constructor ctor, + Map propsByName) + { if (!supportsRecords || !isRecordType(beanClass)) { return false; } @@ -50,27 +68,28 @@ static boolean isRecordConstructor(Class beanClass, Constructor ctor, Map< } try { - Object[] recordComponents = (Object[]) getRecordComponentsMethod.invoke(beanClass); - Class[] componentTypes = new Class[recordComponents.length]; - for (int i = 0; i < recordComponents.length; i++) { - Object recordComponent = recordComponents[i]; - Class type = (Class) getTypeMethod.invoke(recordComponent); - componentTypes[i] = type; - } - - for (int i = 0; i < parameterTypes.length; i++) { - if (parameterTypes[i] != componentTypes[i]) { - return false; - } - } - } catch (IllegalAccessException | InvocationTargetException e) { + Class[] componentTypes = componentTypes(beanClass); + return Arrays.equals(parameterTypes, componentTypes); + } catch (ReflectiveOperationException e) { return false; } - return true; } static boolean isRecordType(Class cls) { Class parent = cls.getSuperclass(); return (parent != null) && "java.lang.Record".equals(parent.getName()); } + + private static Class[] componentTypes(Class recordType) + throws ReflectiveOperationException + { + Object[] recordComponents = (Object[]) getRecordComponentsMethod.invoke(recordType); + Class[] componentTypes = new Class[recordComponents.length]; + for (int i = 0; i < recordComponents.length; i++) { + Object recordComponent = recordComponents[i]; + Class type = (Class) getComponentTypeMethod.invoke(recordComponent); + componentTypes[i] = type; + } + return componentTypes; + } } diff --git a/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/ValueReaderLocator.java b/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/ValueReaderLocator.java index 3a2b94e5..5daa77b1 100644 --- a/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/ValueReaderLocator.java +++ b/jr-objects/src/main/java/com/fasterxml/jackson/jr/ob/impl/ValueReaderLocator.java @@ -452,6 +452,7 @@ protected BeanReader _resolveBeanForDeser(Class raw, POJODefinition beanDef) final Map propMap; Map aliasMapping = null; + boolean isRecord = RecordsHelpers.isRecordType(raw); if (len == 0) { propMap = Collections.emptyMap(); } else { @@ -473,22 +474,31 @@ protected BeanReader _resolveBeanForDeser(Class raw, POJODefinition beanDef) setter = null; } } - // if no setter, field would do as well - if (setter == null) { - if (field == null) { - continue; + if (isRecord) { + try { + field = raw.getDeclaredField(rawProp.name); + } catch (NoSuchFieldException e) { + throw new IllegalStateException("Cannot access field " + rawProp.name + + " of record class " + raw.getName(), e); } - // fields should always be public, but let's just double-check - if (forceAccess) { - field.setAccessible(true); - } else if (!Modifier.isPublic(field.getModifiers())) { - continue; + } else { + // if no setter, field would do as well + if (setter == null) { + if (field == null) { + continue; + } + // fields should always be public, but let's just double-check + if (forceAccess) { + field.setAccessible(true); + } else if (!Modifier.isPublic(field.getModifiers())) { + continue; + } } } - propMap.put(rawProp.name, new BeanPropertyReader(rawProp.name, field, setter)); + propMap.put(rawProp.name, new BeanPropertyReader(rawProp.name, field, setter, i)); - // 25-Jan-2020, tatu: Aliases are bit different because we can not tie them into + // 25-Jan-2020, tatu: Aliases are a bit different because we can not tie them into // specific reader instance, due to resolution of cyclic dependencies. Instead, // we must link via name of primary property, unfortunately: if (rawProp.hasAliases()) { diff --git a/jr-record-test/src/test-jdk17/java/jr/Java17RecordTest.java b/jr-record-test/src/test-jdk17/java/jr/Java17RecordTest.java index 2a5d14de..4ec0c176 100644 --- a/jr-record-test/src/test-jdk17/java/jr/Java17RecordTest.java +++ b/jr-record-test/src/test-jdk17/java/jr/Java17RecordTest.java @@ -2,21 +2,164 @@ import java.util.Map; -import com.fasterxml.jackson.jr.ob.JSON; - import junit.framework.TestCase; +import com.fasterxml.jackson.jr.ob.JSON; + /** * This test is in test module since the JDK version to be tested is higher than other, and hence supports Records. */ public class Java17RecordTest extends TestCase { + private final JSON jsonHandler = JSON.std; + public record Cow(String message, Map object) { } + public record WrapperRecord(Cow cow, String hello) { + } + + public record RecordWithWrapper(Cow cow, Wrapper nested, int someInt) { + } + + record SingleIntRecord(int value) { } + record SingleLongRecord(long value) { } + record SingleStringRecord(String value) { } + + // Degenerate case but supported: + record NoFieldsRecord() { } + // [jackson-jr#94]: Record serialization public void testJava14RecordSerialization() throws Exception { - assertEquals("{\"message\":\"MOO\",\"object\":{\"Foo\":\"Bar\"}}", - JSON.std.asString(new Cow("MOO", Map.of("Foo", "Bar")))); + var expectedString = """ + {"message":"MOO","object":{"Foo":"Bar"}}"""; + Cow expectedObject = new Cow("MOO", Map.of("Foo", "Bar")); + + var json = jsonHandler.asString(expectedObject); + assertEquals(expectedString, json); + + Cow object = jsonHandler.beanFrom(Cow.class, json); + assertEquals(expectedObject, object); + } + + public void testDifferentOrder() throws Exception { + var json = """ + {"object":{"Foo":"Bar"}, "message":"MOO"}"""; + + Cow expectedObject = new Cow("MOO", Map.of("Foo", "Bar")); + Cow object = jsonHandler.beanFrom(Cow.class, json); + assertEquals(expectedObject, object); + } + + public void testNullAndRecord() throws Exception { + var json = """ + {"object": null, "message":"MOO"}"""; + + Cow expectedObject = new Cow("MOO", null); + Cow object = jsonHandler.beanFrom(Cow.class, json); + assertEquals(expectedObject, object); + + assertEquals(new Cow(null, null), jsonHandler.beanFrom(Cow.class,"{}")); + assertNull(jsonHandler.beanFrom(Cow.class, "null")); + } + + public void testPartialParsing() throws Exception { + var json = """ + { "message":"MOO"}"""; + + Cow expectedObject = new Cow("MOO", null); + Cow object = jsonHandler.beanFrom(Cow.class, json); + assertEquals(expectedObject, object); + } + + public void testWhenInsideObject() throws Exception { + var cowJson = """ + {"object": null, "message":"MOO"}"""; + var json = """ + { "cow": %s, "farmerName": "Bob" }""".formatted(cowJson); + + Wrapper wrapper = new Wrapper(); + wrapper.setCow(new Cow("MOO", null)); + wrapper.setFarmerName("Bob"); + + Wrapper object = jsonHandler.beanFrom(Wrapper.class, json); + assertEquals(wrapper, object); + + var jsonNullCow = """ + { "cow": null, "farmerName": "Bob" }"""; + + wrapper = new Wrapper(); + wrapper.setCow(null); + wrapper.setFarmerName("Bob"); + + object = jsonHandler.beanFrom(Wrapper.class, jsonNullCow); + assertEquals(wrapper, object); + + var jsonNoCow = """ + { "farmerName": "Bob" }"""; + + wrapper = new Wrapper(); + wrapper.setCow(null); + wrapper.setFarmerName("Bob"); + + object = jsonHandler.beanFrom(Wrapper.class, jsonNoCow); + assertEquals(wrapper, object); + } + + public void testNested() throws Exception { + var json = """ + { + "hello": "world", + "cow": { "message":"MOO"} + } + """; + + var expected = new WrapperRecord(new Cow("MOO", null), "world"); + var object = jsonHandler.beanFrom(WrapperRecord.class, json); + assertEquals(expected, object); + } + + public void testNestedObjects() throws Exception { + var json = """ + { + "nested": { + "farmerName": "Bob", + "cow": { "message":"MOOO"} + }, + "someInt": 1337, + "cow": { "message":"MOO"} + } + """; + + Wrapper nested = new Wrapper(); + nested.setCow(new Cow("MOOO", null)); + nested.setFarmerName("Bob"); + var expected = new RecordWithWrapper(new Cow("MOO", null), nested, 1337); + var object = jsonHandler.beanFrom(RecordWithWrapper.class, json); + assertEquals(expected, object); + } + + public void testNoFieldRecords() throws Exception { + String json = jsonHandler.asString(new NoFieldsRecord()); + assertEquals("{}", json); + assertEquals(new NoFieldsRecord(), + jsonHandler.beanFrom(NoFieldsRecord.class, json)); + } + + public void testSingleFieldRecords() throws Exception { + SingleIntRecord inputInt = new SingleIntRecord(42); + String json = jsonHandler.asString(inputInt); + assertEquals("{\"value\":42}", json); + assertEquals(inputInt, jsonHandler.beanFrom(SingleIntRecord.class, json)); + + SingleLongRecord inputLong = new SingleLongRecord(-1L); + json = jsonHandler.asString(inputLong); + assertEquals("{\"value\":-1}", json); + assertEquals(inputLong, jsonHandler.beanFrom(SingleLongRecord.class, json)); + + SingleStringRecord inputStr = new SingleStringRecord("abc"); + json = jsonHandler.asString(inputStr); + assertEquals("{\"value\":\"abc\"}", json); + assertEquals(inputStr, jsonHandler.beanFrom(SingleStringRecord.class, json)); } } diff --git a/jr-record-test/src/test-jdk17/java/jr/Wrapper.java b/jr-record-test/src/test-jdk17/java/jr/Wrapper.java new file mode 100644 index 00000000..725575f9 --- /dev/null +++ b/jr-record-test/src/test-jdk17/java/jr/Wrapper.java @@ -0,0 +1,50 @@ +package jr; + +import jr.Java17RecordTest.Cow; + +import java.util.Objects; + +public final class Wrapper { + Cow cow; + String farmerName; + + public Cow getCow() { + return cow; + } + + public void setCow(Cow cow) { + this.cow = cow; + } + + public String getFarmerName() { + return farmerName; + } + + public void setFarmerName(String farmerName) { + this.farmerName = farmerName; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Wrapper wrapper)) { + return false; + } + return Objects.equals(cow, wrapper.cow) && Objects.equals(farmerName, wrapper.farmerName); + } + + @Override + public int hashCode() { + return Objects.hash(cow, farmerName); + } + + @Override + public String toString() { + return "Wrapper{" + + "cow=" + cow + + ", farmerName='" + farmerName + '\'' + + '}'; + } +} diff --git a/release-notes/CREDITS-2.x b/release-notes/CREDITS-2.x index 42d9bbc1..219773cf 100644 --- a/release-notes/CREDITS-2.x +++ b/release-notes/CREDITS-2.x @@ -64,3 +64,8 @@ Julian Honnen (@jhonnen) * Contributed fix for #90: `USE_BIG_DECIMAL_FOR_FLOATS` feature not working when using `JSON.treeFrom()` (2.17.1) + +Tomasz Gawęda (@TomaszGaweda) + +* Contributed #162: Add support for deserializing Java Records + (2.18.0) diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index a2354c67..1685ce40 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -13,7 +13,8 @@ Modules: 2.18.0 (not yet released) -- +#162: Add support for deserializing Java Records + (contributed by Tomasz G) 2.17.1 (04-May-2024)