From 1a3541cc51930ed54635afa4933f520707f07bba Mon Sep 17 00:00:00 2001 From: eranl <1707552+eranl@users.noreply.github.com> Date: Sat, 28 Oct 2023 00:58:10 +0300 Subject: [PATCH 1/4] Add Android Record Module --- android-record/README.md | 31 ++++ android-record/pom.xml | 62 ++++++++ .../androidrecord/AndroidRecordModule.java | 95 ++++++++++++ .../androidrecord/PackageVersion.java.in | 20 +++ .../src/main/resources/META-INF/LICENSE | 8 + .../src/main/resources/META-INF/NOTICE | 20 +++ .../com.fasterxml.jackson.databind.Module | 1 + android-record/src/moditect/module-info.java | 9 ++ .../java/com/android/tools/r8/RecordTag.java | 9 ++ .../androidrecord/AndroidRecordTest.java | 144 ++++++++++++++++++ pom.xml | 1 + 11 files changed, 400 insertions(+) create mode 100644 android-record/README.md create mode 100644 android-record/pom.xml create mode 100644 android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordModule.java create mode 100644 android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/PackageVersion.java.in create mode 100644 android-record/src/main/resources/META-INF/LICENSE create mode 100644 android-record/src/main/resources/META-INF/NOTICE create mode 100644 android-record/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module create mode 100644 android-record/src/moditect/module-info.java create mode 100644 android-record/src/test/java/com/android/tools/r8/RecordTag.java create mode 100644 android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordTest.java diff --git a/android-record/README.md b/android-record/README.md new file mode 100644 index 00000000..6bbd24c2 --- /dev/null +++ b/android-record/README.md @@ -0,0 +1,31 @@ +Module that allows deserialization into records on Android, +where java records are supported through desugaring, +and Jackson's built-in support for records doesn't work, +since the desugared classes have a non-standard super class, +and record component-related reflection methods are missing. + +See [Android Developers Blog article](https://android-developers.googleblog.com/2023/06/records-in-android-studio-flamingo.html) + +Note: this module is a no-op when no Android-desugared records are being deserialized, +so it is safe to use in code shared between Android and non-Android platforms. + +## Usage + +Functionality can be used by registering the module and then just deserializing things +using regular API: + +```java +ObjectMapper mapper = JsonMapper.builder() // or mapper for other dataformats + .addModule(new AndroidRecordModule()) + // add other modules, configure, etc + .build(); +``` + +Maven information for jar is: + +* Group id: `com.fasterxml.jackson.module` +* Artifact id: `jackson-module-android-record` + +## Other + +For Javadocs, Download, see: [Wiki](../../wiki). diff --git a/android-record/pom.xml b/android-record/pom.xml new file mode 100644 index 00000000..83ab2aed --- /dev/null +++ b/android-record/pom.xml @@ -0,0 +1,62 @@ + + + + + + + + com.fasterxml.jackson.module + jackson-modules-base + 2.16.0-SNAPSHOT + + 4.0.0 + + jackson-module-android-record + Jackson module: Android Record Support + bundle + + Support deserialization into records on Android + https://github.com/FasterXML/jackson-modules-base + + + + The Apache Software License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + com/fasterxml/jackson/module/androidrecord + com.fasterxml.jackson.module.androidrecord + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + + + + com.google.code.maven-replacer-plugin + replacer + + + process-packageVersion + generate-sources + + + + + + org.moditect + moditect-maven-plugin + + + + diff --git a/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordModule.java b/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordModule.java new file mode 100644 index 00000000..851acebd --- /dev/null +++ b/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordModule.java @@ -0,0 +1,95 @@ +package com.fasterxml.jackson.module.androidrecord; + +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.DeserializationConfig; +import com.fasterxml.jackson.databind.PropertyName; +import com.fasterxml.jackson.databind.deser.CreatorProperty; +import com.fasterxml.jackson.databind.deser.SettableBeanProperty; +import com.fasterxml.jackson.databind.deser.ValueInstantiator; +import com.fasterxml.jackson.databind.deser.std.StdValueInstantiator; +import com.fasterxml.jackson.databind.introspect.AnnotatedConstructor; +import com.fasterxml.jackson.databind.module.SimpleModule; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.lang.reflect.Parameter; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; + + +/** + * Module that allows deserialization into records + * using the canonical constructor on Android, + * where java records are supported through desugaring, + * and Jackson's built-in support for records doesn't work, + * since the desugared classes have a non-standard super class, + * and record component-related reflection methods are missing. + * + *

+ * See + * Android Developers Blog article + * + *

+ * Note: this module is a no-op when no Android-desugared records are being deserialized, + * so it is safe to use in code shared between Android and non-Android platforms. + * + *

+ * Note: The canonical record constructor is found + * through matching of parameter types with field types. + * Therefore, this module doesn't allow a deserialized desugared record class to have a custom + * constructor with a signature that's any permutation of the canonical one's. + * + * @author Eran Leshem + **/ +public class AndroidRecordModule extends SimpleModule { + @Override + public void setupModule(SetupContext context) { + super.setupModule(context); + context.addValueInstantiators(AndroidRecordModule::findValueInstantiator); + } + + private static ValueInstantiator findValueInstantiator(DeserializationConfig config, BeanDescription beanDesc, + ValueInstantiator defaultInstantiator) { + Class raw = beanDesc.getType().getRawClass(); + if (defaultInstantiator instanceof StdValueInstantiator && raw.getSuperclass() != null + && raw.getSuperclass().getName().equals("com.android.tools.r8.RecordTag")) { + Map componentTypes = typeMap(Arrays.stream(raw.getDeclaredFields()) + .filter(field -> !Modifier.isStatic(field.getModifiers())).map(Field::getGenericType)); + boolean found = false; + for (Constructor constructor: raw.getDeclaredConstructors()) { + Parameter[] parameters = constructor.getParameters(); + Map parameterTypes = typeMap(Arrays.stream(parameters).map(Parameter::getParameterizedType)); + if (! parameterTypes.equals(componentTypes)) { + continue; + } + + if (found) { + throw new IllegalArgumentException(String.format( + "Multiple constructors match set of components for record %s", raw.getName())); + } + + SettableBeanProperty[] properties = new SettableBeanProperty[parameters.length]; + for (int i = 0; i < parameters.length; i++) { + Parameter parameter = parameters[i]; + properties[i] = CreatorProperty.construct(PropertyName.construct(parameter.getName()), config.getTypeFactory() + .constructType(parameter.getParameterizedType()), null, null, null, null, i, null, null); + } + + ((StdValueInstantiator) defaultInstantiator).configureFromObjectSettings(null, null, null, null, + new AnnotatedConstructor(null, constructor, null, null), properties); + constructor.setAccessible(true); + found = true; + } + } + + return defaultInstantiator; + } + + private static Map typeMap(Stream typeStream) { + return typeStream.collect(HashMap::new, (map, type) -> map.merge(type, 1, Integer::sum), Map::putAll); + } +} diff --git a/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/PackageVersion.java.in b/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/PackageVersion.java.in new file mode 100644 index 00000000..7860aa14 --- /dev/null +++ b/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/PackageVersion.java.in @@ -0,0 +1,20 @@ +package @package@; + +import com.fasterxml.jackson.core.Version; +import com.fasterxml.jackson.core.Versioned; +import com.fasterxml.jackson.core.util.VersionUtil; + +/** + * Automatically generated from PackageVersion.java.in during + * packageVersion-generate execution of maven-replacer-plugin in + * pom.xml. + */ +public final class PackageVersion implements Versioned { + public final static Version VERSION = VersionUtil.parseVersion( + "@projectversion@", "@projectgroupid@", "@projectartifactid@"); + + @Override + public Version version() { + return VERSION; + } +} diff --git a/android-record/src/main/resources/META-INF/LICENSE b/android-record/src/main/resources/META-INF/LICENSE new file mode 100644 index 00000000..3433e7e1 --- /dev/null +++ b/android-record/src/main/resources/META-INF/LICENSE @@ -0,0 +1,8 @@ +This copy of Jackson JSON processor `jackson-module-android-record` module is licensed under the +Apache (Software) License, version 2.0 ("the License"). +See the License for details about distribution rights, and the +specific rights regarding derivative works. + +You may obtain a copy of the License at: + +http://www.apache.org/licenses/LICENSE-2.0 \ No newline at end of file diff --git a/android-record/src/main/resources/META-INF/NOTICE b/android-record/src/main/resources/META-INF/NOTICE new file mode 100644 index 00000000..4c976b7b --- /dev/null +++ b/android-record/src/main/resources/META-INF/NOTICE @@ -0,0 +1,20 @@ +# Jackson JSON processor + +Jackson is a high-performance, Free/Open Source JSON processing library. +It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has +been in development since 2007. +It is currently developed by a community of developers, as well as supported +commercially by FasterXML.com. + +## Licensing + +Jackson core and extension components may licensed under different licenses. +To find the details that apply to this artifact see the accompanying LICENSE file. +For more information, including possible other licensing options, contact +FasterXML.com (http://fasterxml.com). + +## Credits + +A list of contributors may be found from CREDITS file, which is included +in some artifacts (usually source distributions); but is always available +from the source code management (SCM) system project uses. diff --git a/android-record/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module b/android-record/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module new file mode 100644 index 00000000..ec03e628 --- /dev/null +++ b/android-record/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module @@ -0,0 +1 @@ +com.fasterxml.jackson.module.androidrecord.AndroidRecordModule diff --git a/android-record/src/moditect/module-info.java b/android-record/src/moditect/module-info.java new file mode 100644 index 00000000..73c3b9cc --- /dev/null +++ b/android-record/src/moditect/module-info.java @@ -0,0 +1,9 @@ +module com.fasterxml.jackson.module.androidrecord { + + requires com.fasterxml.jackson.databind; + + exports com.fasterxml.jackson.module.androidrecord; + + provides com.fasterxml.jackson.databind.Module with + com.fasterxml.jackson.module.androidrecord.AndroidRecordModule; +} diff --git a/android-record/src/test/java/com/android/tools/r8/RecordTag.java b/android-record/src/test/java/com/android/tools/r8/RecordTag.java new file mode 100644 index 00000000..51aacedb --- /dev/null +++ b/android-record/src/test/java/com/android/tools/r8/RecordTag.java @@ -0,0 +1,9 @@ +package com.android.tools.r8; + +/** + * Simulates the super class of Android-desugared records. + * + * @author Eran Leshem + **/ +public class RecordTag { +} diff --git a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordTest.java b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordTest.java new file mode 100644 index 00000000..ba307a4d --- /dev/null +++ b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordTest.java @@ -0,0 +1,144 @@ +package com.fasterxml.jackson.module.androidrecord; + +import com.android.tools.r8.RecordTag; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.InvalidDefinitionException; +import com.fasterxml.jackson.databind.json.JsonMapper; +import junit.framework.TestCase; +import org.junit.Assert; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; + + +/** + * Inner test classes simulate Android-desugared records. + * + * @author Eran Leshem + **/ +public class AndroidRecordTest extends TestCase { + private static final class Simple extends RecordTag { + static int si = 7; + private final int i; + private final int j; + private final String s; + private final List l; + private final AtomicInteger ai; + + private Simple(int i, int j, String s, List l, AtomicInteger ai) { + this.i = i; + this.j = j; + this.s = s; + this.l = l; + this.ai = ai; + } + + int i() { + return i; + } + + String s() { + return s; + } + + List l() { + return l; + } + + int j() { + return j; + } + + AtomicInteger ai() { + return ai; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Simple)) { + return false; + } + Simple simple = (Simple) o; + return i == simple.i && j == simple.j && Objects.equals(s, simple.s) && Objects.equals(l, simple.l) + && Objects.equals(ai.get(), simple.ai.get()); + } + } + + private static final class MultipleConstructors extends RecordTag { + private final int i; + + private MultipleConstructors(int i) { + this.i = i; + } + + private MultipleConstructors(String s) { + i = Integer.parseInt(s); + } + + + private MultipleConstructors(int i, String s) { + this.i = i; + } + + int i() { + return i; + } + } + + + private static final class ConflictingConstructors extends RecordTag { + private final int i; + private final String s; + + private ConflictingConstructors(int i, String s) { + this.i = i; + this.s = s; + } + + private ConflictingConstructors(String s, int i) { + this.i = i; + this.s = s; + } + + public int i() { + return i; + } + + public String s() { + return s; + } + } + + + private final ObjectMapper _objectMapper = JsonMapper.builder() + .visibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) + .addModule(new AndroidRecordModule()).build(); + + public void testSimple() throws JsonProcessingException { + Simple simple = new Simple(9, 3, "foo", Arrays.asList("bar", "baz"), new AtomicInteger(8)); + assertEquals(simple, _objectMapper.readValue(_objectMapper.writeValueAsString(simple), Simple.class)); + } + + public void testMultipleConstructors() throws JsonProcessingException { + assertEquals(9, _objectMapper.readValue(_objectMapper.writeValueAsString(new MultipleConstructors(9)), + MultipleConstructors.class).i()); + assertEquals(9, _objectMapper.readValue(_objectMapper.writeValueAsString( + new MultipleConstructors("9")), MultipleConstructors.class).i()); + assertEquals(9, _objectMapper.readValue(_objectMapper.writeValueAsString( + new MultipleConstructors(9,"foobar")), MultipleConstructors.class).i()); + } + + public void testConflictingConstructors() { + Assert.assertThrows(InvalidDefinitionException.class, + () -> _objectMapper.readValue(_objectMapper.writeValueAsString( + new ConflictingConstructors(9, "foo")), ConflictingConstructors.class)); + } +} diff --git a/pom.xml b/pom.xml index 656004ab..33970cb0 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,7 @@ not datatype, data format, or JAX-RS provider modules. afterburner + android-record blackbird guice guice7 From d6c4b7dffc2c7b630fb40c301e6a41274c6d47c6 Mon Sep 17 00:00:00 2001 From: eranl <1707552+eranl@users.noreply.github.com> Date: Sat, 28 Oct 2023 03:19:50 +0300 Subject: [PATCH 2/4] Add jackson-core dependency, animal-sniffer-maven-plugin, per review comments --- android-record/pom.xml | 17 +++++++++++++++++ pom.xml | 4 ++++ 2 files changed, 21 insertions(+) diff --git a/android-record/pom.xml b/android-record/pom.xml index 83ab2aed..fa8eaf5a 100644 --- a/android-record/pom.xml +++ b/android-record/pom.xml @@ -33,6 +33,10 @@ + + com.fasterxml.jackson.core + jackson-core + com.fasterxml.jackson.core jackson-databind @@ -57,6 +61,19 @@ org.moditect moditect-maven-plugin + + + org.codehaus.mojo + animal-sniffer-maven-plugin + ${version.plugin.animal-sniffer} + + + com.toasttab.android + gummy-bears-api-${version.android.sdk} + ${version.android.sdk.signature} + + + diff --git a/pom.xml b/pom.xml index 33970cb0..21d2ce33 100644 --- a/pom.xml +++ b/pom.xml @@ -55,6 +55,10 @@ not datatype, data format, or JAX-RS provider modules. 9.6 + + 26 + 0.5.1 + 1.23 From 102e00b5b2eba0266472ca8743fef2a6bc7256fd Mon Sep 17 00:00:00 2001 From: eranl <1707552+eranl@users.noreply.github.com> Date: Fri, 3 Nov 2023 02:39:58 +0200 Subject: [PATCH 3/4] Addressed review comments With the goal of maximizing consistency with built-in record support, I copied and "desugared" some unit tests from https://github.com/FasterXML/jackson-databind/tree/2.16/src/test-jdk17/java/com/fasterxml/jackson/databind/records. A few of the test cases are failing, and I marked them with a "Failing" comment and a "notest" name prefix. I'm hoping for guidance about whether and how I should fix them. Fixed handling of getters Added support for injected values Added use of constructor parameter names Skip module if class already has a withArgsCreator --- android-record/pom.xml | 17 + .../androidrecord/AndroidRecordModule.java | 112 +++- android-record/src/moditect/module-info.java | 2 + .../module/androidrecord/BaseMapTest.java | 376 ++++++++++++ .../module/androidrecord/BaseTest.java | 374 +++++++++++ .../androidrecord/RecordBasicsTest.java | 581 ++++++++++++++++++ .../androidrecord/RecordCreatorsTest.java | 118 ++++ 7 files changed, 1560 insertions(+), 20 deletions(-) create mode 100644 android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseMapTest.java create mode 100644 android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseTest.java create mode 100644 android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/RecordBasicsTest.java create mode 100644 android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/RecordCreatorsTest.java diff --git a/android-record/pom.xml b/android-record/pom.xml index fa8eaf5a..217d3964 100644 --- a/android-record/pom.xml +++ b/android-record/pom.xml @@ -37,6 +37,10 @@ com.fasterxml.jackson.core jackson-core + + com.fasterxml.jackson.core + jackson-annotations + com.fasterxml.jackson.core jackson-databind @@ -45,6 +49,19 @@ + + org.apache.maven.plugins + maven-compiler-plugin + true + + + -parameters + + true + true + + + com.google.code.maven-replacer-plugin diff --git a/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordModule.java b/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordModule.java index 851acebd..7cda0243 100644 --- a/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordModule.java +++ b/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordModule.java @@ -1,23 +1,35 @@ package com.fasterxml.jackson.module.androidrecord; +import com.fasterxml.jackson.annotation.JacksonInject; +import com.fasterxml.jackson.databind.AnnotationIntrospector; import com.fasterxml.jackson.databind.BeanDescription; import com.fasterxml.jackson.databind.DeserializationConfig; +import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.PropertyName; +import com.fasterxml.jackson.databind.cfg.MapperConfig; import com.fasterxml.jackson.databind.deser.CreatorProperty; import com.fasterxml.jackson.databind.deser.SettableBeanProperty; import com.fasterxml.jackson.databind.deser.ValueInstantiator; import com.fasterxml.jackson.databind.deser.std.StdValueInstantiator; +import com.fasterxml.jackson.databind.introspect.AccessorNamingStrategy; +import com.fasterxml.jackson.databind.introspect.AnnotatedClass; import com.fasterxml.jackson.databind.introspect.AnnotatedConstructor; +import com.fasterxml.jackson.databind.introspect.AnnotatedMethod; +import com.fasterxml.jackson.databind.introspect.AnnotatedParameter; +import com.fasterxml.jackson.databind.introspect.BasicClassIntrospector; +import com.fasterxml.jackson.databind.introspect.DefaultAccessorNamingStrategy; +import com.fasterxml.jackson.databind.introspect.POJOPropertiesCollector; import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.util.ClassUtil; -import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.lang.reflect.Parameter; import java.lang.reflect.Type; import java.util.Arrays; -import java.util.HashMap; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import java.util.stream.Stream; @@ -38,32 +50,85 @@ * so it is safe to use in code shared between Android and non-Android platforms. * *

- * Note: The canonical record constructor is found - * through matching of parameter types with field types. + * Note: the canonical record constructor is found + * through matching of parameter names and types with fields. * Therefore, this module doesn't allow a deserialized desugared record class to have a custom - * constructor with a signature that's any permutation of the canonical one's. + * constructor with the same set of parameter names and types as the canonical one. * * @author Eran Leshem **/ public class AndroidRecordModule extends SimpleModule { + private static final class AndroidRecordNaming + extends DefaultAccessorNamingStrategy + { + /** + * Names of actual Record components from definition; auto-detected. + */ + private final Set _componentNames; + + private AndroidRecordNaming(MapperConfig config, AnnotatedClass forClass) { + super(config, forClass, + // no setters for (immutable) Records: + null, + // trickier: regular fields are ok (handled differently), but should + // we also allow getter discovery? For now let's do so + "get", "is", null); + _componentNames = getDesugaredRecordComponents(forClass.getRawType()).map(Field::getName) + .collect(Collectors.toSet()); + } + + @Override + public String findNameForRegularGetter(AnnotatedMethod am, String name) + { + // By default, field names are un-prefixed, but verify so that we will not + // include "toString()" or additional custom methods (unless latter are + // annotated for inclusion) + if (_componentNames.contains(name)) { + return name; + } + // but also allow auto-detecting additional getters, if any? + return super.findNameForRegularGetter(am, name); + } + } + + private static class AndroidRecordClassIntrospector extends BasicClassIntrospector { + @Override + protected POJOPropertiesCollector collectProperties(MapperConfig config, JavaType type, MixInResolver r, + boolean forSerialization) { + if (isDesugaredRecordClass(type.getRawClass())) { + AnnotatedClass classDef = _resolveAnnotatedClass(config, type, r); + AccessorNamingStrategy accNaming = new AndroidRecordNaming(config, classDef); + return constructPropertyCollector(config, classDef, type, forSerialization, accNaming); + } + + return super.collectProperties(config, type, r, forSerialization); + } + } + @Override public void setupModule(SetupContext context) { super.setupModule(context); context.addValueInstantiators(AndroidRecordModule::findValueInstantiator); + context.setClassIntrospector(new AndroidRecordClassIntrospector()); + } + + static boolean isDesugaredRecordClass(Class raw) { + return raw.getSuperclass() != null && raw.getSuperclass().getName().equals("com.android.tools.r8.RecordTag"); } private static ValueInstantiator findValueInstantiator(DeserializationConfig config, BeanDescription beanDesc, ValueInstantiator defaultInstantiator) { Class raw = beanDesc.getType().getRawClass(); - if (defaultInstantiator instanceof StdValueInstantiator && raw.getSuperclass() != null - && raw.getSuperclass().getName().equals("com.android.tools.r8.RecordTag")) { - Map componentTypes = typeMap(Arrays.stream(raw.getDeclaredFields()) - .filter(field -> !Modifier.isStatic(field.getModifiers())).map(Field::getGenericType)); + if (! defaultInstantiator.canCreateFromObjectWith() && defaultInstantiator instanceof StdValueInstantiator + && isDesugaredRecordClass(raw)) { + Map components = getDesugaredRecordComponents(raw) + .collect(Collectors.toMap(Field::getName, Field::getGenericType)); boolean found = false; - for (Constructor constructor: raw.getDeclaredConstructors()) { - Parameter[] parameters = constructor.getParameters(); - Map parameterTypes = typeMap(Arrays.stream(parameters).map(Parameter::getParameterizedType)); - if (! parameterTypes.equals(componentTypes)) { + for (AnnotatedConstructor constructor: beanDesc.getConstructors()) { + Parameter[] parameters = constructor.getAnnotated().getParameters(); + Map parameterTypes = Arrays.stream(parameters) + .collect(Collectors.toMap(Parameter::getName, Parameter::getParameterizedType)); + if (! parameterTypes.equals(components)) { continue; } @@ -72,16 +137,23 @@ private static ValueInstantiator findValueInstantiator(DeserializationConfig con "Multiple constructors match set of components for record %s", raw.getName())); } + AnnotationIntrospector intro = config.getAnnotationIntrospector(); SettableBeanProperty[] properties = new SettableBeanProperty[parameters.length]; for (int i = 0; i < parameters.length; i++) { - Parameter parameter = parameters[i]; - properties[i] = CreatorProperty.construct(PropertyName.construct(parameter.getName()), config.getTypeFactory() - .constructType(parameter.getParameterizedType()), null, null, null, null, i, null, null); + AnnotatedParameter parameter = constructor.getParameter(i); + JacksonInject.Value injectable = intro.findInjectableValue(parameter); + PropertyName name = intro.findNameForDeserialization(parameter); + if (name == null || name.isEmpty()) { + name = PropertyName.construct(parameters[i].getName()); + } + + properties[i] = CreatorProperty.construct(name, parameter.getType(), + null, null, parameter.getAllAnnotations(), parameter, i, injectable, null); } ((StdValueInstantiator) defaultInstantiator).configureFromObjectSettings(null, null, null, null, - new AnnotatedConstructor(null, constructor, null, null), properties); - constructor.setAccessible(true); + constructor, properties); + ClassUtil.checkAndFixAccess(constructor.getAnnotated(), false); found = true; } } @@ -89,7 +161,7 @@ private static ValueInstantiator findValueInstantiator(DeserializationConfig con return defaultInstantiator; } - private static Map typeMap(Stream typeStream) { - return typeStream.collect(HashMap::new, (map, type) -> map.merge(type, 1, Integer::sum), Map::putAll); + private static Stream getDesugaredRecordComponents(Class raw) { + return Arrays.stream(raw.getDeclaredFields()).filter(field -> ! Modifier.isStatic(field.getModifiers())); } } diff --git a/android-record/src/moditect/module-info.java b/android-record/src/moditect/module-info.java index 73c3b9cc..12fbad1f 100644 --- a/android-record/src/moditect/module-info.java +++ b/android-record/src/moditect/module-info.java @@ -1,5 +1,7 @@ module com.fasterxml.jackson.module.androidrecord { + requires com.fasterxml.jackson.core; + requires com.fasterxml.jackson.annotation; requires com.fasterxml.jackson.databind; exports com.fasterxml.jackson.module.androidrecord; diff --git a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseMapTest.java b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseMapTest.java new file mode 100644 index 00000000..d084fc16 --- /dev/null +++ b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseMapTest.java @@ -0,0 +1,376 @@ +package com.fasterxml.jackson.module.androidrecord; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.core.FormatSchema; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.deser.std.StdScalarDeserializer; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.ser.std.StdScalarSerializer; +import com.fasterxml.jackson.databind.type.TypeFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public abstract class BaseMapTest + extends BaseTest +{ + private final static Object SINGLETON_OBJECT = new Object(); + + /* + /********************************************************** + /* Shared helper classes + /********************************************************** + */ + + public static class BogusSchema implements FormatSchema { + @Override + public String getSchemaType() { + return "TestFormat"; + } + } + + /** + * Simple wrapper around boolean types, usually to test value + * conversions or wrapping + */ + protected static class BooleanWrapper { + public Boolean b; + + public BooleanWrapper() { } + public BooleanWrapper(Boolean value) { b = value; } + } + + protected static class IntWrapper { + public int i; + + public IntWrapper() { } + public IntWrapper(int value) { i = value; } + } + + protected static class LongWrapper { + public long l; + + public LongWrapper() { } + public LongWrapper(long value) { l = value; } + } + + protected static class FloatWrapper { + public float f; + + public FloatWrapper() { } + public FloatWrapper(float value) { f = value; } + } + + protected static class DoubleWrapper { + public double d; + + public DoubleWrapper() { } + public DoubleWrapper(double value) { d = value; } + } + + /** + * Simple wrapper around String type, usually to test value + * conversions or wrapping + */ + protected static class StringWrapper { + public String str; + + public StringWrapper() { } + public StringWrapper(String value) { + str = value; + } + } + + protected static class ObjectWrapper { + final Object object; + protected ObjectWrapper(final Object object) { + this.object = object; + } + public Object getObject() { return object; } + @JsonCreator + static ObjectWrapper jsonValue(final Object object) { + return new ObjectWrapper(object); + } + } + + protected static class ListWrapper + { + public List list; + + public ListWrapper(@SuppressWarnings("unchecked") T... values) { + list = new ArrayList(); + for (T value : values) { + list.add(value); + } + } + } + + protected static class MapWrapper + { + public Map map; + + public MapWrapper() { } + public MapWrapper(Map m) { + map = m; + } + public MapWrapper(K key, V value) { + map = new LinkedHashMap<>(); + map.put(key, value); + } + } + + protected static class ArrayWrapper + { + public T[] array; + + public ArrayWrapper(T[] v) { + array = v; + } + } + + /** + * Enumeration type with sub-classes per value. + */ + protected enum EnumWithSubClass { + A { @Override public void foobar() { } } + ,B { @Override public void foobar() { } } + ; + + public abstract void foobar(); + } + + public enum ABC { A, B, C; } + + // since 2.8 + public static class Point { + public int x, y; + + protected Point() { } // for deser + public Point(int x0, int y0) { + x = x0; + y = y0; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Point)) { + return false; + } + Point other = (Point) o; + return (other.x == x) && (other.y == y); + } + + @Override + public String toString() { + return String.format("[x=%d, y=%d]", x, y); + } + } + + /* + /********************************************************** + /* Shared serializers + /********************************************************** + */ + + @SuppressWarnings("serial") + public static class UpperCasingSerializer extends StdScalarSerializer + { + public UpperCasingSerializer() { super(String.class); } + + @Override + public void serialize(String value, JsonGenerator gen, + SerializerProvider provider) throws IOException { + gen.writeString(value.toUpperCase()); + } + } + + @SuppressWarnings("serial") + public static class LowerCasingDeserializer extends StdScalarDeserializer + { + public LowerCasingDeserializer() { super(String.class); } + + @Override + public String deserialize(JsonParser p, DeserializationContext ctxt) + throws IOException { + return p.getText().toLowerCase(); + } + } + + /* + /********************************************************** + /* Construction + /********************************************************** + */ + + protected BaseMapTest() { super(); } + + /* + /********************************************************** + /* Factory methods + /********************************************************** + */ + + private static ObjectMapper SHARED_MAPPER; + + protected ObjectMapper sharedMapper() { + if (SHARED_MAPPER == null) { + SHARED_MAPPER = newJsonMapper(); + } + return SHARED_MAPPER; + } + + protected ObjectMapper objectMapper() { + return sharedMapper(); + } + + protected ObjectWriter objectWriter() { + return sharedMapper().writer(); + } + + protected ObjectReader objectReader() { + return sharedMapper().reader(); + } + + protected ObjectReader objectReader(Class cls) { + return sharedMapper().readerFor(cls); + } + + // `public` since 2.16, was only `protected` before then. + // @since 2.10 + public static ObjectMapper newJsonMapper() { + return new JsonMapper().registerModule(new AndroidRecordModule()); + } + + // `public` since 2.16, was only `protected` before then. + // @since 2.10 + public static JsonMapper.Builder jsonMapperBuilder() { + return JsonMapper.builder(); + } + + // @since 2.7 + protected TypeFactory newTypeFactory() { + // this is a work-around; no null modifier added + return TypeFactory.defaultInstance().withModifier(null); + } + + /* + /********************************************************** + /* Additional assert methods + /********************************************************** + */ + + protected void assertEquals(int[] exp, int[] act) + { + assertArrayEquals(exp, act); + } + + protected void assertEquals(byte[] exp, byte[] act) + { + assertArrayEquals(exp, act); + } + + /** + * Helper method for verifying 3 basic cookie cutter cases; + * identity comparison (true), and against null (false), + * or object of different type (false) + */ + protected void assertStandardEquals(Object o) + { + assertTrue(o.equals(o)); + assertFalse(o.equals(null)); + assertFalse(o.equals(SINGLETON_OBJECT)); + // just for fun, let's also call hash code... + o.hashCode(); + } + + /* + /********************************************************** + /* Helper methods, serialization + /********************************************************** + */ + + @SuppressWarnings("unchecked") + protected Map writeAndMap(ObjectMapper m, Object value) + throws IOException + { + String str = m.writeValueAsString(value); + return (Map) m.readValue(str, LinkedHashMap.class); + } + + protected String serializeAsString(ObjectMapper m, Object value) + throws IOException + { + return m.writeValueAsString(value); + } + + protected String serializeAsString(Object value) + throws IOException + { + return serializeAsString(sharedMapper(), value); + } + + protected String asJSONObjectValueString(Object... args) + throws IOException + { + return asJSONObjectValueString(sharedMapper(), args); + } + + protected String asJSONObjectValueString(ObjectMapper m, Object... args) + throws IOException + { + LinkedHashMap map = new LinkedHashMap(); + for (int i = 0, len = args.length; i < len; i += 2) { + map.put(args[i], args[i+1]); + } + return m.writeValueAsString(map); + } + + /* + /********************************************************** + /* Helper methods, deserialization + /********************************************************** + */ + + protected T readAndMapFromString(String input, Class cls) + throws IOException + { + return readAndMapFromString(sharedMapper(), input, cls); + } + + protected T readAndMapFromString(ObjectMapper m, String input, Class cls) throws IOException + { + return (T) m.readValue("\""+input+"\"", cls); + } + + /* + /********************************************************** + /* Helper methods, other + /********************************************************** + */ + + protected TimeZone getUTCTimeZone() { + return TimeZone.getTimeZone("GMT"); + } + + protected byte[] utf8Bytes(String str) { + try { + return str.getBytes("UTF-8"); + } catch (IOException e) { + throw new IllegalArgumentException(e); + } + } +} diff --git a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseTest.java b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseTest.java new file mode 100644 index 00000000..fc56fde2 --- /dev/null +++ b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseTest.java @@ -0,0 +1,374 @@ +package com.fasterxml.jackson.module.androidrecord; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonLocation; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import junit.framework.TestCase; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.StringReader; +import java.io.UncheckedIOException; +import java.util.Arrays; + +public abstract class BaseTest + extends TestCase +{ + /* + /********************************************************** + /* Some sample documents: + /********************************************************** + */ + + protected final static int SAMPLE_SPEC_VALUE_WIDTH = 800; + protected final static int SAMPLE_SPEC_VALUE_HEIGHT = 600; + protected final static String SAMPLE_SPEC_VALUE_TITLE = "View from 15th Floor"; + protected final static String SAMPLE_SPEC_VALUE_TN_URL = "http://www.example.com/image/481989943"; + protected final static int SAMPLE_SPEC_VALUE_TN_HEIGHT = 125; + protected final static String SAMPLE_SPEC_VALUE_TN_WIDTH = "100"; + protected final static int SAMPLE_SPEC_VALUE_TN_ID1 = 116; + protected final static int SAMPLE_SPEC_VALUE_TN_ID2 = 943; + protected final static int SAMPLE_SPEC_VALUE_TN_ID3 = 234; + protected final static int SAMPLE_SPEC_VALUE_TN_ID4 = 38793; + + protected final static String SAMPLE_DOC_JSON_SPEC = + "{\n" + +" \"Image\" : {\n" + +" \"Width\" : "+SAMPLE_SPEC_VALUE_WIDTH+",\n" + +" \"Height\" : "+SAMPLE_SPEC_VALUE_HEIGHT+"," + +"\"Title\" : \""+SAMPLE_SPEC_VALUE_TITLE+"\",\n" + +" \"Thumbnail\" : {\n" + +" \"Url\" : \""+SAMPLE_SPEC_VALUE_TN_URL+"\",\n" + +"\"Height\" : "+SAMPLE_SPEC_VALUE_TN_HEIGHT+",\n" + +" \"Width\" : \""+SAMPLE_SPEC_VALUE_TN_WIDTH+"\"\n" + +" },\n" + +" \"IDs\" : ["+SAMPLE_SPEC_VALUE_TN_ID1+","+SAMPLE_SPEC_VALUE_TN_ID2+","+SAMPLE_SPEC_VALUE_TN_ID3+","+SAMPLE_SPEC_VALUE_TN_ID4+"]\n" + +" }" + +"}" + ; + + /* + /********************************************************** + /* High-level helpers + /********************************************************** + */ + + protected void verifyJsonSpecSampleDoc(JsonParser jp, boolean verifyContents) + throws IOException + { + verifyJsonSpecSampleDoc(jp, verifyContents, true); + } + + protected void verifyJsonSpecSampleDoc(JsonParser jp, boolean verifyContents, + boolean requireNumbers) + throws IOException + { + if (!jp.hasCurrentToken()) { + jp.nextToken(); + } + assertToken(JsonToken.START_OBJECT, jp.currentToken()); // main object + + assertToken(JsonToken.FIELD_NAME, jp.nextToken()); // 'Image' + if (verifyContents) { + verifyFieldName(jp, "Image"); + } + + assertToken(JsonToken.START_OBJECT, jp.nextToken()); // 'image' object + + assertToken(JsonToken.FIELD_NAME, jp.nextToken()); // 'Width' + if (verifyContents) { + verifyFieldName(jp, "Width"); + } + + verifyIntToken(jp.nextToken(), requireNumbers); + if (verifyContents) { + verifyIntValue(jp, SAMPLE_SPEC_VALUE_WIDTH); + } + + assertToken(JsonToken.FIELD_NAME, jp.nextToken()); // 'Height' + if (verifyContents) { + verifyFieldName(jp, "Height"); + } + + verifyIntToken(jp.nextToken(), requireNumbers); + if (verifyContents) { + verifyIntValue(jp, SAMPLE_SPEC_VALUE_HEIGHT); + } + assertToken(JsonToken.FIELD_NAME, jp.nextToken()); // 'Title' + if (verifyContents) { + verifyFieldName(jp, "Title"); + } + assertToken(JsonToken.VALUE_STRING, jp.nextToken()); + assertEquals(SAMPLE_SPEC_VALUE_TITLE, getAndVerifyText(jp)); + assertToken(JsonToken.FIELD_NAME, jp.nextToken()); // 'Thumbnail' + if (verifyContents) { + verifyFieldName(jp, "Thumbnail"); + } + + assertToken(JsonToken.START_OBJECT, jp.nextToken()); // 'thumbnail' object + assertToken(JsonToken.FIELD_NAME, jp.nextToken()); // 'Url' + if (verifyContents) { + verifyFieldName(jp, "Url"); + } + assertToken(JsonToken.VALUE_STRING, jp.nextToken()); + if (verifyContents) { + assertEquals(SAMPLE_SPEC_VALUE_TN_URL, getAndVerifyText(jp)); + } + assertToken(JsonToken.FIELD_NAME, jp.nextToken()); // 'Height' + if (verifyContents) { + verifyFieldName(jp, "Height"); + } + verifyIntToken(jp.nextToken(), requireNumbers); + if (verifyContents) { + verifyIntValue(jp, SAMPLE_SPEC_VALUE_TN_HEIGHT); + } + assertToken(JsonToken.FIELD_NAME, jp.nextToken()); // 'Width' + if (verifyContents) { + verifyFieldName(jp, "Width"); + } + // Width value is actually a String in the example + assertToken(JsonToken.VALUE_STRING, jp.nextToken()); + if (verifyContents) { + assertEquals(SAMPLE_SPEC_VALUE_TN_WIDTH, getAndVerifyText(jp)); + } + + assertToken(JsonToken.END_OBJECT, jp.nextToken()); // 'thumbnail' object + assertToken(JsonToken.FIELD_NAME, jp.nextToken()); // 'IDs' + assertToken(JsonToken.START_ARRAY, jp.nextToken()); // 'ids' array + verifyIntToken(jp.nextToken(), requireNumbers); // ids[0] + if (verifyContents) { + verifyIntValue(jp, SAMPLE_SPEC_VALUE_TN_ID1); + } + verifyIntToken(jp.nextToken(), requireNumbers); // ids[1] + if (verifyContents) { + verifyIntValue(jp, SAMPLE_SPEC_VALUE_TN_ID2); + } + verifyIntToken(jp.nextToken(), requireNumbers); // ids[2] + if (verifyContents) { + verifyIntValue(jp, SAMPLE_SPEC_VALUE_TN_ID3); + } + verifyIntToken(jp.nextToken(), requireNumbers); // ids[3] + if (verifyContents) { + verifyIntValue(jp, SAMPLE_SPEC_VALUE_TN_ID4); + } + assertToken(JsonToken.END_ARRAY, jp.nextToken()); // 'ids' array + + assertToken(JsonToken.END_OBJECT, jp.nextToken()); // 'image' object + + assertToken(JsonToken.END_OBJECT, jp.nextToken()); // main object + } + + private void verifyIntToken(JsonToken t, boolean requireNumbers) + { + if (t == JsonToken.VALUE_NUMBER_INT) { + return; + } + if (requireNumbers) { // to get error + assertToken(JsonToken.VALUE_NUMBER_INT, t); + } + // if not number, must be String + if (t != JsonToken.VALUE_STRING) { + fail("Expected INT or STRING value, got "+t); + } + } + + protected void verifyFieldName(JsonParser p, String expName) + throws IOException + { + assertEquals(expName, p.getText()); + assertEquals(expName, p.currentName()); + } + + protected void verifyIntValue(JsonParser p, long expValue) + throws IOException + { + // First, via textual + assertEquals(String.valueOf(expValue), p.getText()); + } + + /* + /********************************************************** + /* Parser/generator construction + /********************************************************** + */ + + protected JsonParser createParserUsingReader(String input) + throws IOException + { + return createParserUsingReader(new JsonFactory(), input); + } + + protected JsonParser createParserUsingReader(JsonFactory f, String input) + throws IOException + { + return f.createParser(new StringReader(input)); + } + + protected JsonParser createParserUsingStream(String input, String encoding) + throws IOException + { + return createParserUsingStream(new JsonFactory(), input, encoding); + } + + protected JsonParser createParserUsingStream(JsonFactory f, + String input, String encoding) + throws IOException + { + /* 23-Apr-2008, tatus: UTF-32 is not supported by JDK, have to + * use our own codec too (which is not optimal since there's + * a chance both encoder and decoder might have bugs, but ones + * that cancel each other out or such) + */ + byte[] data; + if (encoding.equalsIgnoreCase("UTF-32")) { + data = encodeInUTF32BE(input); + } else { + data = input.getBytes(encoding); + } + InputStream is = new ByteArrayInputStream(data); + return f.createParser(is); + } + + /* + /********************************************************** + /* JDK ser/deser + /********************************************************** + */ + + protected static byte[] jdkSerialize(Object o) + { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(2000); + try (ObjectOutputStream obOut = new ObjectOutputStream(bytes)) { + obOut.writeObject(o); + obOut.close(); + return bytes.toByteArray(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @SuppressWarnings("unchecked") + protected static T jdkDeserialize(byte[] raw) + { + try (ObjectInputStream objIn = new ObjectInputStream(new ByteArrayInputStream(raw))) { + return (T) objIn.readObject(); + } catch (ClassNotFoundException e) { + fail("Missing class: "+e.getMessage()); + return null; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /* + /********************************************************** + /* Additional assertion methods + /********************************************************** + */ + + protected void assertToken(JsonToken expToken, JsonToken actToken) + { + if (actToken != expToken) { + fail("Expected token "+expToken+", current token "+actToken); + } + } + + protected void assertToken(JsonToken expToken, JsonParser jp) + { + assertToken(expToken, jp.currentToken()); + } + + protected void assertType(Object ob, Class expType) + { + if (ob == null) { + fail("Expected an object of type "+expType.getName()+", got null"); + } + Class cls = ob.getClass(); + if (!expType.isAssignableFrom(cls)) { + fail("Expected type "+expType.getName()+", got "+cls.getName()); + } + } + + protected void assertValidLocation(JsonLocation location) { + assertNotNull("Should have non-null location", location); + assertTrue("Should have positive line number", location.getLineNr() > 0); + } + + /** + * @param e Exception to check + * @param anyMatches Array of Strings of which AT LEAST ONE ("any") has to be included + * in {@code e.getMessage()} -- using case-INSENSITIVE comparison + */ + public static void verifyException(Throwable e, String... anyMatches) + { + String msg = e.getMessage(); + String lmsg = (msg == null) ? "" : msg.toLowerCase(); + for (String match : anyMatches) { + String lmatch = match.toLowerCase(); + if (lmsg.contains(lmatch)) { + return; + } + } + fail("Expected an exception with one of substrings (" + +Arrays.asList(anyMatches)+"): got one (of type "+e.getClass().getName() + +") with message \""+msg+"\""); + } + + /** + * Method that gets textual contents of the current token using + * available methods, and ensures results are consistent, before + * returning them + */ + protected String getAndVerifyText(JsonParser jp) + throws IOException + { + // Ok, let's verify other accessors + int actLen = jp.getTextLength(); + char[] ch = jp.getTextCharacters(); + String str2 = new String(ch, jp.getTextOffset(), actLen); + String str = jp.getText(); + + if (str.length() != actLen) { + fail("Internal problem (jp.token == "+jp.currentToken()+"): jp.getText().length() ['"+str+"'] == "+str.length()+"; jp.getTextLength() == "+actLen); + } + assertEquals("String access via getText(), getTextXxx() must be the same", str, str2); + + return str; + } + + /* + /********************************************************** + /* And other helpers + /********************************************************** + */ + + protected byte[] encodeInUTF32BE(String input) + { + int len = input.length(); + byte[] result = new byte[len * 4]; + int ptr = 0; + for (int i = 0; i < len; ++i, ptr += 4) { + char c = input.charAt(i); + result[ptr] = result[ptr+1] = (byte) 0; + result[ptr+2] = (byte) (c >> 8); + result[ptr+3] = (byte) c; + } + return result; + } + + // `static` since 2.16, was only `public` before then. + public static String q(String str) { + return '"'+str+'"'; + } + + // `public` since 2.16, was only `protected` before then. + public static String a2q(String json) { + return json.replace("'", "\""); + } + +} diff --git a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/RecordBasicsTest.java b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/RecordBasicsTest.java new file mode 100644 index 00000000..4d81193b --- /dev/null +++ b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/RecordBasicsTest.java @@ -0,0 +1,581 @@ +package com.fasterxml.jackson.module.androidrecord; + +import com.android.tools.r8.RecordTag; +import com.fasterxml.jackson.annotation.JacksonInject; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.InjectableValues; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.type.TypeFactory; +import com.fasterxml.jackson.databind.util.Converter; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public class RecordBasicsTest extends BaseMapTest +{ + static final class EmptyRecord extends RecordTag { + @Override + public boolean equals(Object o) { + return o instanceof EmptyRecord; + } + + } + + static final class SimpleRecord extends RecordTag { + private final int id; + private final String name; + + SimpleRecord(int id, String name) { + this.id = id; + this.name = name; + } + + public int id() { + return id; + } + + public String name() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof SimpleRecord)) { + return false; + } + SimpleRecord that = (SimpleRecord) o; + return id == that.id && Objects.equals(name, that.name); + } + } + + static final class RecordOfRecord extends RecordTag { + private final SimpleRecord record; + + RecordOfRecord(SimpleRecord record) { + this.record = record; + } + + public SimpleRecord record() { + return record; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof RecordOfRecord)) { + return false; + } + RecordOfRecord that = (RecordOfRecord) o; + return Objects.equals(record, that.record); + } + } + + static final class RecordWithRename extends RecordTag { + private final int id; + @JsonProperty("rename") + private final String name; + + RecordWithRename(int id, @JsonProperty("rename") String name) { + this.id = id; + this.name = name; + } + + public int id() { + return id; + } + + @JsonProperty("rename") + public String name() { + return name; + } + } + + static final class RecordWithHeaderInject extends RecordTag { + private final int id; + @JacksonInject + private final String name; + + RecordWithHeaderInject(int id, @JacksonInject String name) { + this.id = id; + this.name = name; + } + + public int id() { + return id; + } + + @JacksonInject + public String name() { + return name; + } + } + + static final class RecordWithConstructorInject extends RecordTag { + private final int id; + private final String name; + + RecordWithConstructorInject(int id, @JacksonInject String name) { + this.id = id; + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof RecordWithConstructorInject)) return false; + RecordWithConstructorInject that = (RecordWithConstructorInject) o; + return id == that.id && Objects.equals(name, that.name); + } + + @Override + public String toString() { + return "RecordWithConstructorInject{" + + "id=" + id + + ", name='" + name + '\'' + + '}'; + } + } + + // [databind#2992] + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + static final class SnakeRecord extends RecordTag { + private final String myId; + private final String myValue; + + SnakeRecord(String myId, String myValue) { + this.myId = myId; + this.myValue = myValue; + } + + public String myId() { + return myId; + } + + public String myValue() { + return myValue; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof SnakeRecord)) { + return false; + } + SnakeRecord that = (SnakeRecord) o; + return Objects.equals(myId, that.myId) && Objects.equals(myValue, that.myValue); + } + } + + static final class RecordWithJsonDeserialize extends RecordTag { + private final int id; + @JsonDeserialize(converter = StringTrimmer.class) + private final String name; + + RecordWithJsonDeserialize(int id, @JsonDeserialize(converter = StringTrimmer.class) String name) { + this.id = id; + this.name = name; + } + + public int id() { + return id; + } + + @JsonDeserialize(converter = StringTrimmer.class) + public String name() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof RecordWithJsonDeserialize)) { + return false; + } + RecordWithJsonDeserialize that = (RecordWithJsonDeserialize) o; + return id == that.id && Objects.equals(name, that.name); + } + + @Override + public String toString() { + return "RecordWithJsonDeserialize{" + + "id=" + id + + ", name='" + name + '\'' + + '}'; + } + } + + static final class RecordSingleWriteOnly extends RecordTag { + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private final int id; + + RecordSingleWriteOnly(@JsonProperty(access = JsonProperty.Access.WRITE_ONLY) int id) { + this.id = id; + } + + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + public int id() { + return id; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof RecordSingleWriteOnly)) { + return false; + } + RecordSingleWriteOnly that = (RecordSingleWriteOnly) o; + return id == that.id; + } + } + + static final class RecordSomeWriteOnly extends RecordTag { + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private final int id; + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private final String name; + private final String email; + + RecordSomeWriteOnly( + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) int id, + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String name, String email) { + this.id = id; + this.name = name; + this.email = email; + } + + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + public int id() { + return id; + } + + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + public String name() { + return name; + } + + public String email() { + return email; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof RecordSomeWriteOnly)) { + return false; + } + RecordSomeWriteOnly that = (RecordSomeWriteOnly) o; + return id == that.id && Objects.equals(name, that.name) && Objects.equals(email, that.email); + } + } + + static final class RecordAllWriteOnly extends RecordTag { + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private final int id; + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private final String name; + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private final String email; + + RecordAllWriteOnly( + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) int id, + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String name, + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String email) { + this.id = id; + this.name = name; + this.email = email; + } + + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + public int id() { + return id; + } + + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + public String name() { + return name; + } + + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + public String email() { + return email; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof RecordAllWriteOnly)) { + return false; + } + RecordAllWriteOnly that = (RecordAllWriteOnly) o; + return id == that.id && Objects.equals(name, that.name) && Objects.equals(email, that.email); + } + } + + private final ObjectMapper MAPPER = newJsonMapper().disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); + + /* + /********************************************************************** + /* Test methods, Record type introspection + /********************************************************************** + */ + + public void testClassUtil() { + assertFalse(AndroidRecordModule.isDesugaredRecordClass(getClass())); + assertTrue(AndroidRecordModule.isDesugaredRecordClass(SimpleRecord.class)); + assertTrue(AndroidRecordModule.isDesugaredRecordClass(RecordOfRecord.class)); + assertTrue(AndroidRecordModule.isDesugaredRecordClass(RecordWithRename.class)); + } + + public void testRecordJavaType() { + assertFalse(AndroidRecordModule.isDesugaredRecordClass(MAPPER.constructType(getClass()).getRawClass())); + assertTrue(AndroidRecordModule.isDesugaredRecordClass(MAPPER.constructType(SimpleRecord.class).getRawClass())); + assertTrue(AndroidRecordModule.isDesugaredRecordClass(MAPPER.constructType(RecordOfRecord.class).getRawClass())); + assertTrue(AndroidRecordModule.isDesugaredRecordClass(MAPPER.constructType(RecordWithRename.class).getRawClass())); + } + + /* + /********************************************************************** + /* Test methods, default reading/writing Record values + /********************************************************************** + */ + + public void testSerializeSimpleRecord() throws Exception { + String json = MAPPER.writeValueAsString(new SimpleRecord(123, "Bob")); + final Object EXP = map("id", Integer.valueOf(123), "name", "Bob"); + assertEquals(EXP, MAPPER.readValue(json, Object.class)); + } + + public void testDeserializeSimpleRecord() throws Exception { + assertEquals(new SimpleRecord(123, "Bob"), + MAPPER.readValue("{\"id\":123,\"name\":\"Bob\"}", SimpleRecord.class)); + } + + public void testSerializeEmptyRecord() throws Exception { + assertEquals("{}", MAPPER.writeValueAsString(new EmptyRecord())); + } + + public void testDeserializeEmptyRecord() throws Exception { + assertEquals(new EmptyRecord(), + MAPPER.readValue("{}", EmptyRecord.class)); + } + + public void testSerializeRecordOfRecord() throws Exception { + RecordOfRecord record = new RecordOfRecord(new SimpleRecord(123, "Bob")); + String json = MAPPER.writeValueAsString(record); + final Object EXP = Collections.singletonMap("record", + map("id", Integer.valueOf(123), "name", "Bob")); + assertEquals(EXP, MAPPER.readValue(json, Object.class)); + } + + public void testDeserializeRecordOfRecord() throws Exception { + assertEquals(new RecordOfRecord(new SimpleRecord(123, "Bob")), + MAPPER.readValue("{\"record\":{\"id\":123,\"name\":\"Bob\"}}", + RecordOfRecord.class)); + } + + /* + /********************************************************************** + /* Test methods, reading/writing Record values with different config + /********************************************************************** + */ + + public void testSerializeSimpleRecord_DisableAnnotationIntrospector() throws Exception { + SimpleRecord record = new SimpleRecord(123, "Bob"); + + JsonMapper mapper = JsonMapper.builder().addModule(new AndroidRecordModule()) + .configure(MapperFeature.USE_ANNOTATIONS, false) + .build(); + String json = mapper.writeValueAsString(record); + + assertEquals("{\"id\":123,\"name\":\"Bob\"}", json); + } + + public void testDeserializeSimpleRecord_DisableAnnotationIntrospector() throws Exception { + JsonMapper mapper = JsonMapper.builder().addModule(new AndroidRecordModule()) + .configure(MapperFeature.USE_ANNOTATIONS, false) + .build(); + SimpleRecord value = mapper.readValue("{\"id\":123,\"name\":\"Bob\"}", SimpleRecord.class); + + assertEquals(new SimpleRecord(123, "Bob"), value); + } + + /* + /********************************************************************** + /* Test methods, renames, injects + /********************************************************************** + */ + + public void testSerializeJsonRename() throws Exception { + String json = MAPPER.writeValueAsString(new RecordWithRename(123, "Bob")); + final Object EXP = map("id", Integer.valueOf(123), "rename", "Bob"); + assertEquals(EXP, MAPPER.readValue(json, Object.class)); + } + + // Fails in BasicDeserializerFactory._addImplicitConstructorCreators before reaching module logic: + // Invalid type definition for type `com.fasterxml.jackson.module.androidrecord.RecordBasicsTest$RecordWithRename`: Argument #0 of constructor [constructor for `com.fasterxml.jackson.module.androidrecord.RecordBasicsTest$RecordWithRename` (2 args), annotations: [null] has no property name annotation; must have name when multiple-parameter constructor annotated as Creator + public void notestDeserializeJsonRename() throws Exception { + RecordWithRename value = MAPPER.readValue("{\"id\":123,\"rename\":\"Bob\"}", + RecordWithRename.class); + assertEquals(new RecordWithRename(123, "Bob"), value); + } + + // Fails by deserializing successfully, even though annotations on header are "propagated" to the field + /** + * This test-case is just for documentation purpose: + * GOTCHA: Annotations on header will be propagated to the field, leading to this failure. + * + * @see #testDeserializeConstructorInjectRecord() + */ + public void notestDeserializeHeaderInjectRecord_WillFail() throws Exception { + MAPPER.setInjectableValues(new InjectableValues.Std().addValue(String.class, "Bob")); + + try { + MAPPER.readValue("{\"id\":123}", RecordWithHeaderInject.class); + + fail("should not pass"); + } catch (IllegalArgumentException e) { + verifyException(e, "RecordWithHeaderInject#name"); + verifyException(e, "Can not set final java.lang.String field"); + } + } + + + public void testDeserializeConstructorInjectRecord() throws Exception { + MAPPER.setInjectableValues(new InjectableValues.Std().addValue(String.class, "Bob")); + + RecordWithConstructorInject value = MAPPER.readValue("{\"id\":123}", RecordWithConstructorInject.class); + assertEquals(new RecordWithConstructorInject(123, "Bob"), value); + } + + /* + /********************************************************************** + /* Test methods, naming strategy + /********************************************************************** + */ + + // [databind#2992] + public void testNamingStrategy() throws Exception + { + SnakeRecord input = new SnakeRecord("123", "value"); + + String json = MAPPER.writeValueAsString(input); + assertEquals("{\"my_id\":\"123\",\"my_value\":\"value\"}", json); + + SnakeRecord output = MAPPER.readValue(json, SnakeRecord.class); + assertEquals(input, output); + } + + /* + /********************************************************************** + /* Test methods, JsonDeserialize + /********************************************************************** + */ + + // Fails: converter not applied + public void notestDeserializeJsonDeserializeRecord() throws Exception { + RecordWithJsonDeserialize value = MAPPER.readValue("{\"id\":123,\"name\":\" Bob \"}", RecordWithJsonDeserialize.class); + + assertEquals(new RecordWithJsonDeserialize(123, "Bob"), value); + } + + /* + /********************************************************************** + /* Test methods, JsonProperty(access=WRITE_ONLY) + /********************************************************************** + */ + + public void testSerialize_SingleWriteOnlyParameter() throws Exception { + String json = MAPPER.writeValueAsString(new RecordSingleWriteOnly(123)); + + assertEquals("{}", json); + } + + // [databind#3897] + public void testDeserialize_SingleWriteOnlyParameter() throws Exception { + RecordSingleWriteOnly value = MAPPER.readValue("{\"id\":123}", RecordSingleWriteOnly.class); + + assertEquals(new RecordSingleWriteOnly(123), value); + } + + public void testSerialize_SomeWriteOnlyParameter() throws Exception { + String json = MAPPER.writeValueAsString(new RecordSomeWriteOnly(123, "Bob", "bob@example.com")); + + assertEquals("{\"email\":\"bob@example.com\"}", json); + } + + public void testDeserialize_SomeWriteOnlyParameter() throws Exception { + RecordSomeWriteOnly value = MAPPER.readValue( + "{\"id\":123,\"name\":\"Bob\",\"email\":\"bob@example.com\"}", + RecordSomeWriteOnly.class); + + assertEquals(new RecordSomeWriteOnly(123, "Bob", "bob@example.com"), value); + } + + public void testSerialize_AllWriteOnlyParameter() throws Exception { + String json = MAPPER.writeValueAsString(new RecordAllWriteOnly(123, "Bob", "bob@example.com")); + + assertEquals("{}", json); + } + + public void testDeserialize_AllWriteOnlyParameter() throws Exception { + RecordAllWriteOnly value = MAPPER.readValue( + "{\"id\":123,\"name\":\"Bob\",\"email\":\"bob@example.com\"}", + RecordAllWriteOnly.class); + + assertEquals(new RecordAllWriteOnly(123, "Bob", "bob@example.com"), value); + } + + /* + /********************************************************************** + /* Internal helper methods + /********************************************************************** + */ + + private Map map(String key1, Object value1, + String key2, Object value2) { + final Map result = new LinkedHashMap<>(); + result.put(key1, value1); + result.put(key2, value2); + return result; + } + + public static class StringTrimmer implements Converter { + + @Override + public String convert(String value) { + return value.trim(); + } + + @Override + public JavaType getInputType(TypeFactory typeFactory) { + return typeFactory.constructType(String.class); + } + + @Override + public JavaType getOutputType(TypeFactory typeFactory) { + return typeFactory.constructType(String.class); + } + } +} diff --git a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/RecordCreatorsTest.java b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/RecordCreatorsTest.java new file mode 100644 index 00000000..db30309e --- /dev/null +++ b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/RecordCreatorsTest.java @@ -0,0 +1,118 @@ +package com.fasterxml.jackson.module.androidrecord; + +import com.android.tools.r8.RecordTag; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; + +public class RecordCreatorsTest extends BaseMapTest +{ + static final class RecordWithCanonicalCtorOverride extends RecordTag { + private final int id; + private final String name; + + public int id() { + return id; + } + + public String name() { + return name; + } + + public RecordWithCanonicalCtorOverride(int id, String name) { + this.id = id; + this.name = "name"; + } + } + + static final class RecordWithAltCtor extends RecordTag { + private final int id; + private final String name; + + RecordWithAltCtor(int id, String name) { + this.id = id; + this.name = name; + } + + public int id() { + return id; + } + + public String name() { + return name; + } + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public RecordWithAltCtor(@JsonProperty("id") int id) { + this(id, "name2"); + } + } + + // [databind#2980] + static final class RecordWithDelegation extends RecordTag { + private final String value; + + public String value() { + return value; + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public RecordWithDelegation(String value) { + this.value = "del:" + value; + } + + @JsonValue() + public String getValue() { + return "val:" + value; + } + + public String accessValueForTest() { + return value; + } + } + + private final ObjectMapper MAPPER = newJsonMapper(); + + /* + /********************************************************************** + /* Test methods, alternate constructors + /********************************************************************** + */ + + public void testDeserializeWithCanonicalCtorOverride() throws Exception { + RecordWithCanonicalCtorOverride value = MAPPER.readValue("{\"id\":123,\"name\":\"Bob\"}", + RecordWithCanonicalCtorOverride.class); + assertEquals(123, value.id()); + assertEquals("name", value.name()); + } + + // Fails: Implicit canonical constructor still works too + public void notestDeserializeWithAltCtor() throws Exception { + RecordWithAltCtor value = MAPPER.readValue("{\"id\":2812}", + RecordWithAltCtor.class); + assertEquals(2812, value.id()); + assertEquals("name2", value.name()); + + // "Implicit" canonical constructor can no longer be used when there's explicit constructor + try { + MAPPER.readValue("{\"id\":2812,\"name\":\"Bob\"}", + RecordWithAltCtor.class); + fail("should not pass"); + } catch (UnrecognizedPropertyException e) { + verifyException(e, "Unrecognized"); + verifyException(e, "\"name\""); + verifyException(e, "RecordWithAltCtor"); + } + } + + // [databind#2980] + public void testDeserializeWithDelegatingCtor() throws Exception { + RecordWithDelegation value = MAPPER.readValue(q("foobar"), + RecordWithDelegation.class); + assertEquals("del:foobar", value.accessValueForTest()); + + assertEquals(q("val:del:foobar"), MAPPER.writeValueAsString(value)); + } +} From cc269c5cf630dde7af9b4686dd093d17c1ab7b3f Mon Sep 17 00:00:00 2001 From: eranl <1707552+eranl@users.noreply.github.com> Date: Thu, 9 Nov 2023 20:02:17 +0200 Subject: [PATCH 4/4] Updated doc. Moved failing tests to 'failing' package. Added test for differing generic parameter types. Pruned BaseTest and BaseMapTest. Added comment about '-parameters' compiler option. --- android-record/pom.xml | 171 ++++---- .../androidrecord/AndroidRecordModule.java | 21 +- .../androidrecord/AndroidRecordTest.java | 23 +- .../module/androidrecord/BaseMapTest.java | 365 +----------------- .../module/androidrecord/BaseTest.java | 338 +--------------- .../androidrecord/RecordBasicsTest.java | 128 +----- .../androidrecord/RecordCreatorsTest.java | 44 --- .../failing/RecordBasicsFailingTest.java | 166 ++++++++ .../failing/RecordCreatorsFailingTest.java | 61 +++ 9 files changed, 345 insertions(+), 972 deletions(-) create mode 100644 android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/failing/RecordBasicsFailingTest.java create mode 100644 android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/failing/RecordCreatorsFailingTest.java diff --git a/android-record/pom.xml b/android-record/pom.xml index 217d3964..c5d313c9 100644 --- a/android-record/pom.xml +++ b/android-record/pom.xml @@ -1,96 +1,97 @@ - - - - - - - com.fasterxml.jackson.module - jackson-modules-base - 2.16.0-SNAPSHOT - - 4.0.0 + + + + + + + com.fasterxml.jackson.module + jackson-modules-base + 2.16.0-SNAPSHOT + + 4.0.0 - jackson-module-android-record - Jackson module: Android Record Support - bundle + jackson-module-android-record + Jackson module: Android Record Support + bundle - Support deserialization into records on Android - https://github.com/FasterXML/jackson-modules-base + Support deserialization into records on Android + https://github.com/FasterXML/jackson-modules-base - - - The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - repo - - + + + The Apache Software License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + - - - com/fasterxml/jackson/module/androidrecord - com.fasterxml.jackson.module.androidrecord - + + + com/fasterxml/jackson/module/androidrecord + com.fasterxml.jackson.module.androidrecord + - - - com.fasterxml.jackson.core - jackson-core - - - com.fasterxml.jackson.core - jackson-annotations - - - com.fasterxml.jackson.core - jackson-databind - - + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-databind + + - - - - org.apache.maven.plugins - maven-compiler-plugin - true - - - -parameters - - true - true - - + + + + org.apache.maven.plugins + maven-compiler-plugin + true + + + + -parameters + + true + true + + - - - com.google.code.maven-replacer-plugin - replacer - - - process-packageVersion - generate-sources - - - + + + com.google.code.maven-replacer-plugin + replacer + + + process-packageVersion + generate-sources + + + - - org.moditect - moditect-maven-plugin - + + org.moditect + moditect-maven-plugin + - - org.codehaus.mojo - animal-sniffer-maven-plugin - ${version.plugin.animal-sniffer} - - - com.toasttab.android - gummy-bears-api-${version.android.sdk} - ${version.android.sdk.signature} - - - - - + + org.codehaus.mojo + animal-sniffer-maven-plugin + ${version.plugin.animal-sniffer} + + + com.toasttab.android + gummy-bears-api-${version.android.sdk} + ${version.android.sdk.signature} + + + + + diff --git a/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordModule.java b/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordModule.java index 7cda0243..362e96c6 100644 --- a/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordModule.java +++ b/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordModule.java @@ -34,10 +34,8 @@ /** - * Module that allows deserialization into records - * using the canonical constructor on Android, - * where java records are supported through desugaring, - * and Jackson's built-in support for records doesn't work, + * Module that allows (de)serialization of records using the canonical constructor and accessors on Android, + * where java records are supported through desugaring, and Jackson's built-in support for records doesn't work, * since the desugared classes have a non-standard super class, * and record component-related reflection methods are missing. * @@ -46,14 +44,17 @@ * Android Developers Blog article * *

- * Note: this module is a no-op when no Android-desugared records are being deserialized, + * An attempt was made to make this module as consistent with Jackson's built-in support for records as possible, + * but gaps exist when using some of Jackson's advanced mapping features. + * + *

+ * Note: this module is a no-op when no Android-desugared records are being (de)serialized, * so it is safe to use in code shared between Android and non-Android platforms. * *

- * Note: the canonical record constructor is found - * through matching of parameter names and types with fields. - * Therefore, this module doesn't allow a deserialized desugared record class to have a custom - * constructor with the same set of parameter names and types as the canonical one. + * Note: the canonical record constructor is found through matching of parameter names and types with fields. + * Therefore, this module doesn't allow a deserialized desugared record to have a custom constructor + * with the same set of parameter names and types as the canonical one. * * @author Eran Leshem **/ @@ -70,8 +71,6 @@ private AndroidRecordNaming(MapperConfig config, AnnotatedClass forClass) { super(config, forClass, // no setters for (immutable) Records: null, - // trickier: regular fields are ok (handled differently), but should - // we also allow getter discovery? For now let's do so "get", "is", null); _componentNames = getDesugaredRecordComponents(forClass.getRawType()).map(Field::getName) .collect(Collectors.toSet()); diff --git a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordTest.java b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordTest.java index ba307a4d..935bcfc1 100644 --- a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordTest.java +++ b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordTest.java @@ -74,18 +74,26 @@ public boolean equals(Object o) { private static final class MultipleConstructors extends RecordTag { private final int i; + private final List l; - private MultipleConstructors(int i) { + private MultipleConstructors(int i, List l) { this.i = i; + this.l = l; } - private MultipleConstructors(String s) { + private MultipleConstructors(String s, List l) { i = Integer.parseInt(s); + this.l = l; } + private MultipleConstructors(int i, String s, List l) { + this.i = i; + this.l = l; + } - private MultipleConstructors(int i, String s) { + private MultipleConstructors(List l, int i) { this.i = i; + this.l = null; } int i() { @@ -128,12 +136,15 @@ public void testSimple() throws JsonProcessingException { } public void testMultipleConstructors() throws JsonProcessingException { - assertEquals(9, _objectMapper.readValue(_objectMapper.writeValueAsString(new MultipleConstructors(9)), + List l = Arrays.asList("bar", "baz"); + assertEquals(9, _objectMapper.readValue(_objectMapper.writeValueAsString(new MultipleConstructors(9, l)), MultipleConstructors.class).i()); assertEquals(9, _objectMapper.readValue(_objectMapper.writeValueAsString( - new MultipleConstructors("9")), MultipleConstructors.class).i()); + new MultipleConstructors("9", l)), MultipleConstructors.class).i()); + assertEquals(9, _objectMapper.readValue(_objectMapper.writeValueAsString( + new MultipleConstructors(9,"foobar", l)), MultipleConstructors.class).i()); assertEquals(9, _objectMapper.readValue(_objectMapper.writeValueAsString( - new MultipleConstructors(9,"foobar")), MultipleConstructors.class).i()); + new MultipleConstructors(Arrays.asList(1, 2), 9)), MultipleConstructors.class).i()); } public void testConflictingConstructors() { diff --git a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseMapTest.java b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseMapTest.java index d084fc16..cdccf5e5 100644 --- a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseMapTest.java +++ b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseMapTest.java @@ -1,376 +1,13 @@ package com.fasterxml.jackson.module.androidrecord; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.core.FormatSchema; -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.ObjectReader; -import com.fasterxml.jackson.databind.ObjectWriter; -import com.fasterxml.jackson.databind.SerializerProvider; -import com.fasterxml.jackson.databind.deser.std.StdScalarDeserializer; import com.fasterxml.jackson.databind.json.JsonMapper; -import com.fasterxml.jackson.databind.ser.std.StdScalarSerializer; -import com.fasterxml.jackson.databind.type.TypeFactory; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.TimeZone; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; public abstract class BaseMapTest extends BaseTest { - private final static Object SINGLETON_OBJECT = new Object(); - - /* - /********************************************************** - /* Shared helper classes - /********************************************************** - */ - - public static class BogusSchema implements FormatSchema { - @Override - public String getSchemaType() { - return "TestFormat"; - } - } - - /** - * Simple wrapper around boolean types, usually to test value - * conversions or wrapping - */ - protected static class BooleanWrapper { - public Boolean b; - - public BooleanWrapper() { } - public BooleanWrapper(Boolean value) { b = value; } - } - - protected static class IntWrapper { - public int i; - - public IntWrapper() { } - public IntWrapper(int value) { i = value; } - } - - protected static class LongWrapper { - public long l; - - public LongWrapper() { } - public LongWrapper(long value) { l = value; } - } - - protected static class FloatWrapper { - public float f; - - public FloatWrapper() { } - public FloatWrapper(float value) { f = value; } - } - - protected static class DoubleWrapper { - public double d; - - public DoubleWrapper() { } - public DoubleWrapper(double value) { d = value; } - } - - /** - * Simple wrapper around String type, usually to test value - * conversions or wrapping - */ - protected static class StringWrapper { - public String str; - - public StringWrapper() { } - public StringWrapper(String value) { - str = value; - } - } - - protected static class ObjectWrapper { - final Object object; - protected ObjectWrapper(final Object object) { - this.object = object; - } - public Object getObject() { return object; } - @JsonCreator - static ObjectWrapper jsonValue(final Object object) { - return new ObjectWrapper(object); - } - } - - protected static class ListWrapper - { - public List list; - - public ListWrapper(@SuppressWarnings("unchecked") T... values) { - list = new ArrayList(); - for (T value : values) { - list.add(value); - } - } - } - - protected static class MapWrapper - { - public Map map; - - public MapWrapper() { } - public MapWrapper(Map m) { - map = m; - } - public MapWrapper(K key, V value) { - map = new LinkedHashMap<>(); - map.put(key, value); - } - } - - protected static class ArrayWrapper - { - public T[] array; - - public ArrayWrapper(T[] v) { - array = v; - } - } - - /** - * Enumeration type with sub-classes per value. - */ - protected enum EnumWithSubClass { - A { @Override public void foobar() { } } - ,B { @Override public void foobar() { } } - ; - - public abstract void foobar(); - } - - public enum ABC { A, B, C; } - - // since 2.8 - public static class Point { - public int x, y; - - protected Point() { } // for deser - public Point(int x0, int y0) { - x = x0; - y = y0; - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof Point)) { - return false; - } - Point other = (Point) o; - return (other.x == x) && (other.y == y); - } - - @Override - public String toString() { - return String.format("[x=%d, y=%d]", x, y); - } - } - - /* - /********************************************************** - /* Shared serializers - /********************************************************** - */ - - @SuppressWarnings("serial") - public static class UpperCasingSerializer extends StdScalarSerializer - { - public UpperCasingSerializer() { super(String.class); } - - @Override - public void serialize(String value, JsonGenerator gen, - SerializerProvider provider) throws IOException { - gen.writeString(value.toUpperCase()); - } - } - - @SuppressWarnings("serial") - public static class LowerCasingDeserializer extends StdScalarDeserializer - { - public LowerCasingDeserializer() { super(String.class); } - - @Override - public String deserialize(JsonParser p, DeserializationContext ctxt) - throws IOException { - return p.getText().toLowerCase(); - } - } - - /* - /********************************************************** - /* Construction - /********************************************************** - */ - - protected BaseMapTest() { super(); } - - /* - /********************************************************** - /* Factory methods - /********************************************************** - */ - - private static ObjectMapper SHARED_MAPPER; - - protected ObjectMapper sharedMapper() { - if (SHARED_MAPPER == null) { - SHARED_MAPPER = newJsonMapper(); - } - return SHARED_MAPPER; - } - - protected ObjectMapper objectMapper() { - return sharedMapper(); - } - - protected ObjectWriter objectWriter() { - return sharedMapper().writer(); - } - - protected ObjectReader objectReader() { - return sharedMapper().reader(); - } - - protected ObjectReader objectReader(Class cls) { - return sharedMapper().readerFor(cls); - } - - // `public` since 2.16, was only `protected` before then. - // @since 2.10 - public static ObjectMapper newJsonMapper() { + protected static ObjectMapper newJsonMapper() { return new JsonMapper().registerModule(new AndroidRecordModule()); } - // `public` since 2.16, was only `protected` before then. - // @since 2.10 - public static JsonMapper.Builder jsonMapperBuilder() { - return JsonMapper.builder(); - } - - // @since 2.7 - protected TypeFactory newTypeFactory() { - // this is a work-around; no null modifier added - return TypeFactory.defaultInstance().withModifier(null); - } - - /* - /********************************************************** - /* Additional assert methods - /********************************************************** - */ - - protected void assertEquals(int[] exp, int[] act) - { - assertArrayEquals(exp, act); - } - - protected void assertEquals(byte[] exp, byte[] act) - { - assertArrayEquals(exp, act); - } - - /** - * Helper method for verifying 3 basic cookie cutter cases; - * identity comparison (true), and against null (false), - * or object of different type (false) - */ - protected void assertStandardEquals(Object o) - { - assertTrue(o.equals(o)); - assertFalse(o.equals(null)); - assertFalse(o.equals(SINGLETON_OBJECT)); - // just for fun, let's also call hash code... - o.hashCode(); - } - - /* - /********************************************************** - /* Helper methods, serialization - /********************************************************** - */ - - @SuppressWarnings("unchecked") - protected Map writeAndMap(ObjectMapper m, Object value) - throws IOException - { - String str = m.writeValueAsString(value); - return (Map) m.readValue(str, LinkedHashMap.class); - } - - protected String serializeAsString(ObjectMapper m, Object value) - throws IOException - { - return m.writeValueAsString(value); - } - - protected String serializeAsString(Object value) - throws IOException - { - return serializeAsString(sharedMapper(), value); - } - - protected String asJSONObjectValueString(Object... args) - throws IOException - { - return asJSONObjectValueString(sharedMapper(), args); - } - - protected String asJSONObjectValueString(ObjectMapper m, Object... args) - throws IOException - { - LinkedHashMap map = new LinkedHashMap(); - for (int i = 0, len = args.length; i < len; i += 2) { - map.put(args[i], args[i+1]); - } - return m.writeValueAsString(map); - } - - /* - /********************************************************** - /* Helper methods, deserialization - /********************************************************** - */ - - protected T readAndMapFromString(String input, Class cls) - throws IOException - { - return readAndMapFromString(sharedMapper(), input, cls); - } - - protected T readAndMapFromString(ObjectMapper m, String input, Class cls) throws IOException - { - return (T) m.readValue("\""+input+"\"", cls); - } - - /* - /********************************************************** - /* Helper methods, other - /********************************************************** - */ - - protected TimeZone getUTCTimeZone() { - return TimeZone.getTimeZone("GMT"); - } - - protected byte[] utf8Bytes(String str) { - try { - return str.getBytes("UTF-8"); - } catch (IOException e) { - throw new IllegalArgumentException(e); - } - } } diff --git a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseTest.java b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseTest.java index fc56fde2..01ac3b0a 100644 --- a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseTest.java +++ b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseTest.java @@ -1,310 +1,18 @@ package com.fasterxml.jackson.module.androidrecord; -import com.fasterxml.jackson.core.JsonFactory; -import com.fasterxml.jackson.core.JsonLocation; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonToken; import junit.framework.TestCase; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.ObjectInputStream; -import java.io.ObjectOutputStream; -import java.io.StringReader; -import java.io.UncheckedIOException; import java.util.Arrays; public abstract class BaseTest extends TestCase { - /* - /********************************************************** - /* Some sample documents: - /********************************************************** - */ - - protected final static int SAMPLE_SPEC_VALUE_WIDTH = 800; - protected final static int SAMPLE_SPEC_VALUE_HEIGHT = 600; - protected final static String SAMPLE_SPEC_VALUE_TITLE = "View from 15th Floor"; - protected final static String SAMPLE_SPEC_VALUE_TN_URL = "http://www.example.com/image/481989943"; - protected final static int SAMPLE_SPEC_VALUE_TN_HEIGHT = 125; - protected final static String SAMPLE_SPEC_VALUE_TN_WIDTH = "100"; - protected final static int SAMPLE_SPEC_VALUE_TN_ID1 = 116; - protected final static int SAMPLE_SPEC_VALUE_TN_ID2 = 943; - protected final static int SAMPLE_SPEC_VALUE_TN_ID3 = 234; - protected final static int SAMPLE_SPEC_VALUE_TN_ID4 = 38793; - - protected final static String SAMPLE_DOC_JSON_SPEC = - "{\n" - +" \"Image\" : {\n" - +" \"Width\" : "+SAMPLE_SPEC_VALUE_WIDTH+",\n" - +" \"Height\" : "+SAMPLE_SPEC_VALUE_HEIGHT+"," - +"\"Title\" : \""+SAMPLE_SPEC_VALUE_TITLE+"\",\n" - +" \"Thumbnail\" : {\n" - +" \"Url\" : \""+SAMPLE_SPEC_VALUE_TN_URL+"\",\n" - +"\"Height\" : "+SAMPLE_SPEC_VALUE_TN_HEIGHT+",\n" - +" \"Width\" : \""+SAMPLE_SPEC_VALUE_TN_WIDTH+"\"\n" - +" },\n" - +" \"IDs\" : ["+SAMPLE_SPEC_VALUE_TN_ID1+","+SAMPLE_SPEC_VALUE_TN_ID2+","+SAMPLE_SPEC_VALUE_TN_ID3+","+SAMPLE_SPEC_VALUE_TN_ID4+"]\n" - +" }" - +"}" - ; - - /* - /********************************************************** - /* High-level helpers - /********************************************************** - */ - - protected void verifyJsonSpecSampleDoc(JsonParser jp, boolean verifyContents) - throws IOException - { - verifyJsonSpecSampleDoc(jp, verifyContents, true); - } - - protected void verifyJsonSpecSampleDoc(JsonParser jp, boolean verifyContents, - boolean requireNumbers) - throws IOException - { - if (!jp.hasCurrentToken()) { - jp.nextToken(); - } - assertToken(JsonToken.START_OBJECT, jp.currentToken()); // main object - - assertToken(JsonToken.FIELD_NAME, jp.nextToken()); // 'Image' - if (verifyContents) { - verifyFieldName(jp, "Image"); - } - - assertToken(JsonToken.START_OBJECT, jp.nextToken()); // 'image' object - - assertToken(JsonToken.FIELD_NAME, jp.nextToken()); // 'Width' - if (verifyContents) { - verifyFieldName(jp, "Width"); - } - - verifyIntToken(jp.nextToken(), requireNumbers); - if (verifyContents) { - verifyIntValue(jp, SAMPLE_SPEC_VALUE_WIDTH); - } - - assertToken(JsonToken.FIELD_NAME, jp.nextToken()); // 'Height' - if (verifyContents) { - verifyFieldName(jp, "Height"); - } - - verifyIntToken(jp.nextToken(), requireNumbers); - if (verifyContents) { - verifyIntValue(jp, SAMPLE_SPEC_VALUE_HEIGHT); - } - assertToken(JsonToken.FIELD_NAME, jp.nextToken()); // 'Title' - if (verifyContents) { - verifyFieldName(jp, "Title"); - } - assertToken(JsonToken.VALUE_STRING, jp.nextToken()); - assertEquals(SAMPLE_SPEC_VALUE_TITLE, getAndVerifyText(jp)); - assertToken(JsonToken.FIELD_NAME, jp.nextToken()); // 'Thumbnail' - if (verifyContents) { - verifyFieldName(jp, "Thumbnail"); - } - - assertToken(JsonToken.START_OBJECT, jp.nextToken()); // 'thumbnail' object - assertToken(JsonToken.FIELD_NAME, jp.nextToken()); // 'Url' - if (verifyContents) { - verifyFieldName(jp, "Url"); - } - assertToken(JsonToken.VALUE_STRING, jp.nextToken()); - if (verifyContents) { - assertEquals(SAMPLE_SPEC_VALUE_TN_URL, getAndVerifyText(jp)); - } - assertToken(JsonToken.FIELD_NAME, jp.nextToken()); // 'Height' - if (verifyContents) { - verifyFieldName(jp, "Height"); - } - verifyIntToken(jp.nextToken(), requireNumbers); - if (verifyContents) { - verifyIntValue(jp, SAMPLE_SPEC_VALUE_TN_HEIGHT); - } - assertToken(JsonToken.FIELD_NAME, jp.nextToken()); // 'Width' - if (verifyContents) { - verifyFieldName(jp, "Width"); - } - // Width value is actually a String in the example - assertToken(JsonToken.VALUE_STRING, jp.nextToken()); - if (verifyContents) { - assertEquals(SAMPLE_SPEC_VALUE_TN_WIDTH, getAndVerifyText(jp)); - } - - assertToken(JsonToken.END_OBJECT, jp.nextToken()); // 'thumbnail' object - assertToken(JsonToken.FIELD_NAME, jp.nextToken()); // 'IDs' - assertToken(JsonToken.START_ARRAY, jp.nextToken()); // 'ids' array - verifyIntToken(jp.nextToken(), requireNumbers); // ids[0] - if (verifyContents) { - verifyIntValue(jp, SAMPLE_SPEC_VALUE_TN_ID1); - } - verifyIntToken(jp.nextToken(), requireNumbers); // ids[1] - if (verifyContents) { - verifyIntValue(jp, SAMPLE_SPEC_VALUE_TN_ID2); - } - verifyIntToken(jp.nextToken(), requireNumbers); // ids[2] - if (verifyContents) { - verifyIntValue(jp, SAMPLE_SPEC_VALUE_TN_ID3); - } - verifyIntToken(jp.nextToken(), requireNumbers); // ids[3] - if (verifyContents) { - verifyIntValue(jp, SAMPLE_SPEC_VALUE_TN_ID4); - } - assertToken(JsonToken.END_ARRAY, jp.nextToken()); // 'ids' array - - assertToken(JsonToken.END_OBJECT, jp.nextToken()); // 'image' object - - assertToken(JsonToken.END_OBJECT, jp.nextToken()); // main object - } - - private void verifyIntToken(JsonToken t, boolean requireNumbers) - { - if (t == JsonToken.VALUE_NUMBER_INT) { - return; - } - if (requireNumbers) { // to get error - assertToken(JsonToken.VALUE_NUMBER_INT, t); - } - // if not number, must be String - if (t != JsonToken.VALUE_STRING) { - fail("Expected INT or STRING value, got "+t); - } - } - - protected void verifyFieldName(JsonParser p, String expName) - throws IOException - { - assertEquals(expName, p.getText()); - assertEquals(expName, p.currentName()); - } - - protected void verifyIntValue(JsonParser p, long expValue) - throws IOException - { - // First, via textual - assertEquals(String.valueOf(expValue), p.getText()); - } - - /* - /********************************************************** - /* Parser/generator construction - /********************************************************** - */ - - protected JsonParser createParserUsingReader(String input) - throws IOException - { - return createParserUsingReader(new JsonFactory(), input); - } - - protected JsonParser createParserUsingReader(JsonFactory f, String input) - throws IOException - { - return f.createParser(new StringReader(input)); - } - - protected JsonParser createParserUsingStream(String input, String encoding) - throws IOException - { - return createParserUsingStream(new JsonFactory(), input, encoding); - } - - protected JsonParser createParserUsingStream(JsonFactory f, - String input, String encoding) - throws IOException - { - /* 23-Apr-2008, tatus: UTF-32 is not supported by JDK, have to - * use our own codec too (which is not optimal since there's - * a chance both encoder and decoder might have bugs, but ones - * that cancel each other out or such) - */ - byte[] data; - if (encoding.equalsIgnoreCase("UTF-32")) { - data = encodeInUTF32BE(input); - } else { - data = input.getBytes(encoding); - } - InputStream is = new ByteArrayInputStream(data); - return f.createParser(is); - } - - /* - /********************************************************** - /* JDK ser/deser - /********************************************************** - */ - - protected static byte[] jdkSerialize(Object o) - { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(2000); - try (ObjectOutputStream obOut = new ObjectOutputStream(bytes)) { - obOut.writeObject(o); - obOut.close(); - return bytes.toByteArray(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - @SuppressWarnings("unchecked") - protected static T jdkDeserialize(byte[] raw) - { - try (ObjectInputStream objIn = new ObjectInputStream(new ByteArrayInputStream(raw))) { - return (T) objIn.readObject(); - } catch (ClassNotFoundException e) { - fail("Missing class: "+e.getMessage()); - return null; - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - /* - /********************************************************** - /* Additional assertion methods - /********************************************************** - */ - - protected void assertToken(JsonToken expToken, JsonToken actToken) - { - if (actToken != expToken) { - fail("Expected token "+expToken+", current token "+actToken); - } - } - - protected void assertToken(JsonToken expToken, JsonParser jp) - { - assertToken(expToken, jp.currentToken()); - } - - protected void assertType(Object ob, Class expType) - { - if (ob == null) { - fail("Expected an object of type "+expType.getName()+", got null"); - } - Class cls = ob.getClass(); - if (!expType.isAssignableFrom(cls)) { - fail("Expected type "+expType.getName()+", got "+cls.getName()); - } - } - - protected void assertValidLocation(JsonLocation location) { - assertNotNull("Should have non-null location", location); - assertTrue("Should have positive line number", location.getLineNr() > 0); - } - /** * @param e Exception to check * @param anyMatches Array of Strings of which AT LEAST ONE ("any") has to be included * in {@code e.getMessage()} -- using case-INSENSITIVE comparison */ - public static void verifyException(Throwable e, String... anyMatches) + protected static void verifyException(Throwable e, String... anyMatches) { String msg = e.getMessage(); String lmsg = (msg == null) ? "" : msg.toLowerCase(); @@ -319,56 +27,14 @@ public static void verifyException(Throwable e, String... anyMatches) +") with message \""+msg+"\""); } - /** - * Method that gets textual contents of the current token using - * available methods, and ensures results are consistent, before - * returning them - */ - protected String getAndVerifyText(JsonParser jp) - throws IOException - { - // Ok, let's verify other accessors - int actLen = jp.getTextLength(); - char[] ch = jp.getTextCharacters(); - String str2 = new String(ch, jp.getTextOffset(), actLen); - String str = jp.getText(); - - if (str.length() != actLen) { - fail("Internal problem (jp.token == "+jp.currentToken()+"): jp.getText().length() ['"+str+"'] == "+str.length()+"; jp.getTextLength() == "+actLen); - } - assertEquals("String access via getText(), getTextXxx() must be the same", str, str2); - - return str; - } - /* /********************************************************** /* And other helpers /********************************************************** */ - protected byte[] encodeInUTF32BE(String input) - { - int len = input.length(); - byte[] result = new byte[len * 4]; - int ptr = 0; - for (int i = 0; i < len; ++i, ptr += 4) { - char c = input.charAt(i); - result[ptr] = result[ptr+1] = (byte) 0; - result[ptr+2] = (byte) (c >> 8); - result[ptr+3] = (byte) c; - } - return result; - } - // `static` since 2.16, was only `public` before then. - public static String q(String str) { + static String q(String str) { return '"'+str+'"'; } - - // `public` since 2.16, was only `protected` before then. - public static String a2q(String json) { - return json.replace("'", "\""); - } - } diff --git a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/RecordBasicsTest.java b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/RecordBasicsTest.java index 4d81193b..468475a9 100644 --- a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/RecordBasicsTest.java +++ b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/RecordBasicsTest.java @@ -4,16 +4,12 @@ import com.fasterxml.jackson.annotation.JacksonInject; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.InjectableValues; -import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonNaming; import com.fasterxml.jackson.databind.json.JsonMapper; -import com.fasterxml.jackson.databind.type.TypeFactory; -import com.fasterxml.jackson.databind.util.Converter; import java.util.Collections; import java.util.LinkedHashMap; @@ -104,26 +100,6 @@ public String name() { } } - static final class RecordWithHeaderInject extends RecordTag { - private final int id; - @JacksonInject - private final String name; - - RecordWithHeaderInject(int id, @JacksonInject String name) { - this.id = id; - this.name = name; - } - - public int id() { - return id; - } - - @JacksonInject - public String name() { - return name; - } - } - static final class RecordWithConstructorInject extends RecordTag { private final int id; private final String name; @@ -182,46 +158,6 @@ public boolean equals(Object o) { } } - static final class RecordWithJsonDeserialize extends RecordTag { - private final int id; - @JsonDeserialize(converter = StringTrimmer.class) - private final String name; - - RecordWithJsonDeserialize(int id, @JsonDeserialize(converter = StringTrimmer.class) String name) { - this.id = id; - this.name = name; - } - - public int id() { - return id; - } - - @JsonDeserialize(converter = StringTrimmer.class) - public String name() { - return name; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof RecordWithJsonDeserialize)) { - return false; - } - RecordWithJsonDeserialize that = (RecordWithJsonDeserialize) o; - return id == that.id && Objects.equals(name, that.name); - } - - @Override - public String toString() { - return "RecordWithJsonDeserialize{" + - "id=" + id + - ", name='" + name + '\'' + - '}'; - } - } - static final class RecordSingleWriteOnly extends RecordTag { @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private final int id; @@ -433,35 +369,6 @@ public void testSerializeJsonRename() throws Exception { assertEquals(EXP, MAPPER.readValue(json, Object.class)); } - // Fails in BasicDeserializerFactory._addImplicitConstructorCreators before reaching module logic: - // Invalid type definition for type `com.fasterxml.jackson.module.androidrecord.RecordBasicsTest$RecordWithRename`: Argument #0 of constructor [constructor for `com.fasterxml.jackson.module.androidrecord.RecordBasicsTest$RecordWithRename` (2 args), annotations: [null] has no property name annotation; must have name when multiple-parameter constructor annotated as Creator - public void notestDeserializeJsonRename() throws Exception { - RecordWithRename value = MAPPER.readValue("{\"id\":123,\"rename\":\"Bob\"}", - RecordWithRename.class); - assertEquals(new RecordWithRename(123, "Bob"), value); - } - - // Fails by deserializing successfully, even though annotations on header are "propagated" to the field - /** - * This test-case is just for documentation purpose: - * GOTCHA: Annotations on header will be propagated to the field, leading to this failure. - * - * @see #testDeserializeConstructorInjectRecord() - */ - public void notestDeserializeHeaderInjectRecord_WillFail() throws Exception { - MAPPER.setInjectableValues(new InjectableValues.Std().addValue(String.class, "Bob")); - - try { - MAPPER.readValue("{\"id\":123}", RecordWithHeaderInject.class); - - fail("should not pass"); - } catch (IllegalArgumentException e) { - verifyException(e, "RecordWithHeaderInject#name"); - verifyException(e, "Can not set final java.lang.String field"); - } - } - - public void testDeserializeConstructorInjectRecord() throws Exception { MAPPER.setInjectableValues(new InjectableValues.Std().addValue(String.class, "Bob")); @@ -487,19 +394,6 @@ public void testNamingStrategy() throws Exception assertEquals(input, output); } - /* - /********************************************************************** - /* Test methods, JsonDeserialize - /********************************************************************** - */ - - // Fails: converter not applied - public void notestDeserializeJsonDeserializeRecord() throws Exception { - RecordWithJsonDeserialize value = MAPPER.readValue("{\"id\":123,\"name\":\" Bob \"}", RecordWithJsonDeserialize.class); - - assertEquals(new RecordWithJsonDeserialize(123, "Bob"), value); - } - /* /********************************************************************** /* Test methods, JsonProperty(access=WRITE_ONLY) @@ -553,29 +447,11 @@ public void testDeserialize_AllWriteOnlyParameter() throws Exception { /********************************************************************** */ - private Map map(String key1, Object value1, - String key2, Object value2) { + private static Map map(String key1, Object value1, + String key2, Object value2) { final Map result = new LinkedHashMap<>(); result.put(key1, value1); result.put(key2, value2); return result; } - - public static class StringTrimmer implements Converter { - - @Override - public String convert(String value) { - return value.trim(); - } - - @Override - public JavaType getInputType(TypeFactory typeFactory) { - return typeFactory.constructType(String.class); - } - - @Override - public JavaType getOutputType(TypeFactory typeFactory) { - return typeFactory.constructType(String.class); - } - } } diff --git a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/RecordCreatorsTest.java b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/RecordCreatorsTest.java index db30309e..e4fe30d4 100644 --- a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/RecordCreatorsTest.java +++ b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/RecordCreatorsTest.java @@ -2,10 +2,8 @@ import com.android.tools.r8.RecordTag; import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonValue; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; public class RecordCreatorsTest extends BaseMapTest { @@ -27,29 +25,6 @@ public RecordWithCanonicalCtorOverride(int id, String name) { } } - static final class RecordWithAltCtor extends RecordTag { - private final int id; - private final String name; - - RecordWithAltCtor(int id, String name) { - this.id = id; - this.name = name; - } - - public int id() { - return id; - } - - public String name() { - return name; - } - - @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) - public RecordWithAltCtor(@JsonProperty("id") int id) { - this(id, "name2"); - } - } - // [databind#2980] static final class RecordWithDelegation extends RecordTag { private final String value; @@ -88,25 +63,6 @@ public void testDeserializeWithCanonicalCtorOverride() throws Exception { assertEquals("name", value.name()); } - // Fails: Implicit canonical constructor still works too - public void notestDeserializeWithAltCtor() throws Exception { - RecordWithAltCtor value = MAPPER.readValue("{\"id\":2812}", - RecordWithAltCtor.class); - assertEquals(2812, value.id()); - assertEquals("name2", value.name()); - - // "Implicit" canonical constructor can no longer be used when there's explicit constructor - try { - MAPPER.readValue("{\"id\":2812,\"name\":\"Bob\"}", - RecordWithAltCtor.class); - fail("should not pass"); - } catch (UnrecognizedPropertyException e) { - verifyException(e, "Unrecognized"); - verifyException(e, "\"name\""); - verifyException(e, "RecordWithAltCtor"); - } - } - // [databind#2980] public void testDeserializeWithDelegatingCtor() throws Exception { RecordWithDelegation value = MAPPER.readValue(q("foobar"), diff --git a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/failing/RecordBasicsFailingTest.java b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/failing/RecordBasicsFailingTest.java new file mode 100644 index 00000000..7f13bd14 --- /dev/null +++ b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/failing/RecordBasicsFailingTest.java @@ -0,0 +1,166 @@ +package com.fasterxml.jackson.module.androidrecord.failing; + +import com.android.tools.r8.RecordTag; +import com.fasterxml.jackson.annotation.JacksonInject; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.InjectableValues; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.type.TypeFactory; +import com.fasterxml.jackson.databind.util.Converter; +import com.fasterxml.jackson.module.androidrecord.BaseMapTest; + +import java.util.Objects; + +public class RecordBasicsFailingTest extends BaseMapTest +{ + static final class RecordWithRename extends RecordTag { + private final int id; + @JsonProperty("rename") + private final String name; + + RecordWithRename(int id, @JsonProperty("rename") String name) { + this.id = id; + this.name = name; + } + + public int id() { + return id; + } + + @JsonProperty("rename") + public String name() { + return name; + } + } + + static final class RecordWithHeaderInject extends RecordTag { + private final int id; + @JacksonInject + private final String name; + + RecordWithHeaderInject(int id, @JacksonInject String name) { + this.id = id; + this.name = name; + } + + public int id() { + return id; + } + + @JacksonInject + public String name() { + return name; + } + } + + static final class RecordWithJsonDeserialize extends RecordTag { + private final int id; + @JsonDeserialize(converter = StringTrimmer.class) + private final String name; + + RecordWithJsonDeserialize(int id, @JsonDeserialize(converter = StringTrimmer.class) String name) { + this.id = id; + this.name = name; + } + + public int id() { + return id; + } + + @JsonDeserialize(converter = StringTrimmer.class) + public String name() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof RecordWithJsonDeserialize)) { + return false; + } + RecordWithJsonDeserialize that = (RecordWithJsonDeserialize) o; + return id == that.id && Objects.equals(name, that.name); + } + + @Override + public String toString() { + return "RecordWithJsonDeserialize{" + + "id=" + id + + ", name='" + name + '\'' + + '}'; + } + } + + private final ObjectMapper MAPPER = newJsonMapper().disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); + + /* + /********************************************************************** + /* Test methods, renames, injects + /********************************************************************** + */ + + // Fails in BasicDeserializerFactory._addImplicitConstructorCreators before reaching module logic: + // Invalid type definition for type `com.fasterxml.jackson.module.androidrecord.RecordBasicsTest$RecordWithRename`: Argument #0 of constructor [constructor for `com.fasterxml.jackson.module.androidrecord.RecordBasicsTest$RecordWithRename` (2 args), annotations: [null] has no property name annotation; must have name when multiple-parameter constructor annotated as Creator + public void testDeserializeJsonRename() throws Exception { + RecordWithRename value = MAPPER.readValue("{\"id\":123,\"rename\":\"Bob\"}", + RecordWithRename.class); + assertEquals(new RecordWithRename(123, "Bob"), value); + } + + // Fails by deserializing successfully, even though annotations on header are "propagated" to the field + /** + * This test-case is just for documentation purpose: + * GOTCHA: Annotations on header will be propagated to the field, leading to this failure. + * + * @see #testDeserializeConstructorInjectRecord() + */ + public void testDeserializeHeaderInjectRecord_WillFail() throws Exception { + MAPPER.setInjectableValues(new InjectableValues.Std().addValue(String.class, "Bob")); + + try { + MAPPER.readValue("{\"id\":123}", RecordWithHeaderInject.class); + + fail("should not pass"); + } catch (IllegalArgumentException e) { + verifyException(e, "RecordWithHeaderInject#name"); + verifyException(e, "Can not set final java.lang.String field"); + } + } + + /* + /********************************************************************** + /* Test methods, JsonDeserialize + /********************************************************************** + */ + + // Fails: converter not applied + public void testDeserializeJsonDeserializeRecord() throws Exception { + RecordWithJsonDeserialize value = MAPPER.readValue("{\"id\":123,\"name\":\" Bob \"}", + RecordWithJsonDeserialize.class); + + assertEquals(new RecordWithJsonDeserialize(123, "Bob"), value); + } + + public static class StringTrimmer implements Converter { + + @Override + public String convert(String value) { + return value.trim(); + } + + @Override + public JavaType getInputType(TypeFactory typeFactory) { + return typeFactory.constructType(String.class); + } + + @Override + public JavaType getOutputType(TypeFactory typeFactory) { + return typeFactory.constructType(String.class); + } + } +} diff --git a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/failing/RecordCreatorsFailingTest.java b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/failing/RecordCreatorsFailingTest.java new file mode 100644 index 00000000..e6731d53 --- /dev/null +++ b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/failing/RecordCreatorsFailingTest.java @@ -0,0 +1,61 @@ +package com.fasterxml.jackson.module.androidrecord.failing; + +import com.android.tools.r8.RecordTag; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; +import com.fasterxml.jackson.module.androidrecord.BaseMapTest; + +public class RecordCreatorsFailingTest extends BaseMapTest +{ + static final class RecordWithAltCtor extends RecordTag { + private final int id; + private final String name; + + RecordWithAltCtor(int id, String name) { + this.id = id; + this.name = name; + } + + public int id() { + return id; + } + + public String name() { + return name; + } + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public RecordWithAltCtor(@JsonProperty("id") int id) { + this(id, "name2"); + } + } + + private final ObjectMapper MAPPER = newJsonMapper(); + + /* + /********************************************************************** + /* Test methods, alternate constructors + /********************************************************************** + */ + + // Fails: Implicit canonical constructor still works too + public void testDeserializeWithAltCtor() throws Exception { + RecordWithAltCtor value = MAPPER.readValue("{\"id\":2812}", + RecordWithAltCtor.class); + assertEquals(2812, value.id()); + assertEquals("name2", value.name()); + + // "Implicit" canonical constructor can no longer be used when there's explicit constructor + try { + MAPPER.readValue("{\"id\":2812,\"name\":\"Bob\"}", + RecordWithAltCtor.class); + fail("should not pass"); + } catch (UnrecognizedPropertyException e) { + verifyException(e, "Unrecognized"); + verifyException(e, "\"name\""); + verifyException(e, "RecordWithAltCtor"); + } + } +}