Implement reflective support for Java Records (#1381)

* Standardize around JDK 8

* Update GJF to support newer JDKs

* Fix misc java 8 issues in tests

* Prepare java 16/records checking at runtime

* Implement real RecordJsonAdapter

* Spotless

* Prepare build for JDK 16+

* Fix property name for kapt

* Small cleanup

* Make FallbackEnum java-8 happy

* Remove animalsniffer

* Fix format

* Add opens for ExtendsPlatformClassWithProtectedFields

* Return null every time in shim for main tests

* Use JDK 16 + release 8 to replace animalsniffer

* Simplify accessor accessible handling

* Remove manifest attrs

* Fix typo

* Fix KCT tests + upgrade it

* Cover another

* Try explicit kotlin daemon args for java 17?

* Disable 17-ea for now until kotlin 1.5.30

* Add JsonQualifier and Json(name) tests + fix qualifiers

* Ensure constructor is accessible

* GJF it properly

* GJF 1.11

* Unwrap InvocationTargetException

* Use MethodHandle for constructor

* Use MethodHandle for accessor too

* Revert "Remove manifest attrs"

This reverts commit 3eb768fd6904bb5c979aa01c3c182e0fb9329d62.

* Proper MR jar

* *actually* fix GJF, which wasn't getting applied before

We can just enable this everywhere now since we require JDK 16 anyway

* Make IDE happy about modules access

* Fixup records tests to play nice with modules

Gotta be public

* Add complex smoke test

* Remove comment

Not a regression test in this case
This commit is contained in:
Zac Sweers
2021-08-23 12:00:06 -04:00
committed by GitHub
parent 2572c29e42
commit 95250b0359
20 changed files with 653 additions and 104 deletions

View File

@@ -19,14 +19,49 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
kotlin("jvm")
id("com.vanniktech.maven.publish")
id("ru.vyarus.animalsniffer")
}
val mainSourceSet by sourceSets.named("main")
val java16 by sourceSets.creating {
java {
srcDir("src/main/java16")
}
}
tasks.named<JavaCompile>("compileJava16Java") {
javaCompiler.set(
javaToolchains.compilerFor {
languageVersion.set(JavaLanguageVersion.of(16))
}
)
options.release.set(16)
}
// Package our actual RecordJsonAdapter from java16 sources in and denote it as an MRJAR
tasks.named<Jar>("jar") {
from(java16.output) {
into("META-INF/versions/16")
}
manifest {
attributes("Multi-Release" to "true")
}
}
configurations {
"java16Implementation" {
extendsFrom(api.get())
extendsFrom(implementation.get())
}
}
tasks.withType<Test>().configureEach {
// ExtendsPlatformClassWithProtectedField tests a case where we set a protected ByteArrayOutputStream.buf field
jvmArgs("--add-opens=java.base/java.io=ALL-UNNAMED")
}
tasks.withType<KotlinCompile>()
.configureEach {
kotlinOptions {
jvmTarget = "1.6"
if (name.contains("test", true)) {
@Suppress("SuspiciousCollectionReassignment") // It's not suspicious
freeCompilerArgs += listOf("-Xopt-in=kotlin.ExperimentalStdlibApi")
@@ -35,6 +70,8 @@ tasks.withType<KotlinCompile>()
}
dependencies {
// So the j16 source set can "see" main Moshi sources
"java16Implementation"(mainSourceSet.output)
compileOnly(Dependencies.jsr305)
api(Dependencies.okio)

View File

@@ -0,0 +1,30 @@
/*
* Copyright (C) 2021 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
`java-library`
}
tasks.withType<JavaCompile>().configureEach {
options.release.set(16)
}
dependencies {
testImplementation(project(":moshi"))
testCompileOnly(Dependencies.jsr305)
testImplementation(Dependencies.Testing.junit)
testImplementation(Dependencies.Testing.truth)
}

View File

@@ -0,0 +1,195 @@
/*
* Copyright (C) 2021 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.squareup.moshi.records;
import static com.google.common.truth.Truth.assertThat;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import com.squareup.moshi.FromJson;
import com.squareup.moshi.Json;
import com.squareup.moshi.JsonQualifier;
import com.squareup.moshi.Moshi;
import com.squareup.moshi.ToJson;
import com.squareup.moshi.Types;
import java.io.IOException;
import java.lang.annotation.Retention;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.junit.Test;
public final class RecordsTest {
private final Moshi moshi = new Moshi.Builder().build();
@Test
public void smokeTest() throws IOException {
var stringAdapter = moshi.adapter(String.class);
var adapter =
moshi
.newBuilder()
.add(CharSequence.class, stringAdapter)
.add(Types.subtypeOf(CharSequence.class), stringAdapter)
.add(Types.supertypeOf(CharSequence.class), stringAdapter)
.build()
.adapter(SmokeTestType.class);
var instance =
new SmokeTestType(
"John",
"Smith",
25,
List.of("American"),
70.5f,
null,
true,
List.of("super wildcards!"),
List.of("extend wildcards!"),
List.of("unbounded"),
List.of("objectList"),
new int[] {1, 2, 3},
new String[] {"fav", "arrays"},
Map.of("italian", "pasta"),
Set.of(List.of(Map.of("someKey", new int[] {1}))),
new Map[] {Map.of("Hello", "value")});
var json = adapter.toJson(instance);
var deserialized = adapter.fromJson(json);
assertThat(deserialized).isEqualTo(instance);
}
public static record SmokeTestType(
@Json(name = "first_name") String firstName,
@Json(name = "last_name") String lastName,
int age,
List<String> nationalities,
float weight,
Boolean tattoos, // Boxed primitive test
boolean hasChildren,
List<? super CharSequence> superWildcard,
List<? extends CharSequence> extendsWildcard,
List<?> unboundedWildcard,
List<Object> objectList,
int[] favoriteThreeNumbers,
String[] favoriteArrayValues,
Map<String, String> foodPreferences,
Set<List<Map<String, int[]>>> setListMapArrayInt,
Map<String, Object>[] nestedArray) {
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
SmokeTestType that = (SmokeTestType) o;
return age == that.age
&& Float.compare(that.weight, weight) == 0
&& hasChildren == that.hasChildren
&& firstName.equals(that.firstName)
&& lastName.equals(that.lastName)
&& nationalities.equals(that.nationalities)
&& Objects.equals(tattoos, that.tattoos)
&& superWildcard.equals(that.superWildcard)
&& extendsWildcard.equals(that.extendsWildcard)
&& unboundedWildcard.equals(that.unboundedWildcard)
&& objectList.equals(that.objectList)
&& Arrays.equals(favoriteThreeNumbers, that.favoriteThreeNumbers)
&& Arrays.equals(favoriteArrayValues, that.favoriteArrayValues)
&& foodPreferences.equals(that.foodPreferences)
// && setListMapArrayInt.equals(that.setListMapArrayInt) // Nested array equality doesn't
// carry over
&& Arrays.equals(nestedArray, that.nestedArray);
}
@Override
public int hashCode() {
int result =
Objects.hash(
firstName,
lastName,
age,
nationalities,
weight,
tattoos,
hasChildren,
superWildcard,
extendsWildcard,
unboundedWildcard,
objectList,
foodPreferences,
setListMapArrayInt);
result = 31 * result + Arrays.hashCode(favoriteThreeNumbers);
result = 31 * result + Arrays.hashCode(favoriteArrayValues);
result = 31 * result + Arrays.hashCode(nestedArray);
return result;
}
}
@Test
public void genericRecord() throws IOException {
var adapter =
moshi.<GenericRecord<String>>adapter(
Types.newParameterizedTypeWithOwner(
RecordsTest.class, GenericRecord.class, String.class));
assertThat(adapter.fromJson("{\"value\":\"Okay!\"}")).isEqualTo(new GenericRecord<>("Okay!"));
}
public static record GenericRecord<T>(T value) {}
@Test
public void genericBoundedRecord() throws IOException {
var adapter =
moshi.<GenericBoundedRecord<Integer>>adapter(
Types.newParameterizedTypeWithOwner(
RecordsTest.class, GenericBoundedRecord.class, Integer.class));
assertThat(adapter.fromJson("{\"value\":4}")).isEqualTo(new GenericBoundedRecord<>(4));
}
@Test
public void qualifiedValues() throws IOException {
var adapter = moshi.newBuilder().add(new ColorAdapter()).build().adapter(QualifiedValues.class);
assertThat(adapter.fromJson("{\"value\":\"#ff0000\"}"))
.isEqualTo(new QualifiedValues(16711680));
}
public static record QualifiedValues(@HexColor int value) {}
@Retention(RUNTIME)
@JsonQualifier
@interface HexColor {}
/** Converts strings like #ff0000 to the corresponding color ints. */
public static class ColorAdapter {
@ToJson
public String toJson(@HexColor int rgb) {
return String.format("#%06x", rgb);
}
@FromJson
@HexColor
public int fromJson(String rgb) {
return Integer.parseInt(rgb.substring(1), 16);
}
}
public static record GenericBoundedRecord<T extends Number>(T value) {}
@Test
public void jsonName() throws IOException {
var adapter = moshi.adapter(JsonName.class);
assertThat(adapter.fromJson("{\"actualValue\":3}")).isEqualTo(new JsonName(3));
}
public static record JsonName(@Json(name = "actualValue") int value) {}
}

View File

@@ -51,6 +51,7 @@ public final class Moshi {
BUILT_IN_FACTORIES.add(CollectionJsonAdapter.FACTORY);
BUILT_IN_FACTORIES.add(MapJsonAdapter.FACTORY);
BUILT_IN_FACTORIES.add(ArrayJsonAdapter.FACTORY);
BUILT_IN_FACTORIES.add(RecordJsonAdapter.FACTORY);
BUILT_IN_FACTORIES.add(ClassJsonAdapter.FACTORY);
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) 2021 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.squareup.moshi;
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.reflect.Type;
import java.util.Set;
import javax.annotation.Nullable;
/**
* This is just a simple shim for linking in {@link StandardJsonAdapters} and swapped with a real
* implementation in Java 16 via MR Jar.
*/
final class RecordJsonAdapter<T> extends JsonAdapter<T> {
static final JsonAdapter.Factory FACTORY =
new JsonAdapter.Factory() {
@Nullable
@Override
public JsonAdapter<?> create(
Type type, Set<? extends Annotation> annotations, Moshi moshi) {
return null;
}
};
@Nullable
@Override
public T fromJson(JsonReader reader) throws IOException {
throw new AssertionError();
}
@Override
public void toJson(JsonWriter writer, @Nullable T value) throws IOException {
throw new AssertionError();
}
}

View File

@@ -0,0 +1,218 @@
/*
* Copyright (C) 2021 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.squareup.moshi;
import static java.lang.invoke.MethodType.methodType;
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.RecordComponent;
import java.lang.reflect.Type;
import java.lang.reflect.TypeVariable;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
/**
* A {@link JsonAdapter} that supports Java {@code record} classes via reflection.
*
* <p><em>NOTE:</em> Java records require JDK 16 or higher.
*/
final class RecordJsonAdapter<T> extends JsonAdapter<T> {
static final JsonAdapter.Factory FACTORY =
(type, annotations, moshi) -> {
if (!annotations.isEmpty()) {
return null;
}
if (!(type instanceof Class) && !(type instanceof ParameterizedType)) {
return null;
}
var rawType = Types.getRawType(type);
if (!rawType.isRecord()) {
return null;
}
Map<String, Type> mappedTypeArgs = null;
if (type instanceof ParameterizedType parameterizedType) {
Type[] typeArgs = parameterizedType.getActualTypeArguments();
var typeVars = rawType.getTypeParameters();
mappedTypeArgs = new LinkedHashMap<>(typeArgs.length);
for (int i = 0; i < typeArgs.length; ++i) {
var typeVarName = typeVars[i].getName();
var materialized = typeArgs[i];
mappedTypeArgs.put(typeVarName, materialized);
}
}
var components = rawType.getRecordComponents();
var bindings = new LinkedHashMap<String, ComponentBinding<?>>();
var constructorParams = new Class<?>[components.length];
var lookup = MethodHandles.lookup();
for (int i = 0, componentsLength = components.length; i < componentsLength; i++) {
RecordComponent component = components[i];
constructorParams[i] = component.getType();
var name = component.getName();
var componentType = component.getGenericType();
if (componentType instanceof TypeVariable<?> typeVariable) {
var typeVarName = typeVariable.getName();
if (mappedTypeArgs == null) {
throw new AssertionError(
"No mapped type arguments found for type '" + typeVarName + "'");
}
var mappedType = mappedTypeArgs.get(typeVarName);
if (mappedType == null) {
throw new AssertionError(
"No materialized type argument found for type '" + typeVarName + "'");
}
componentType = mappedType;
}
var jsonName = name;
Set<Annotation> qualifiers = null;
for (var annotation : component.getDeclaredAnnotations()) {
if (annotation instanceof Json jsonAnnotation) {
jsonName = jsonAnnotation.name();
} else {
if (annotation.annotationType().isAnnotationPresent(JsonQualifier.class)) {
if (qualifiers == null) {
qualifiers = new LinkedHashSet<>();
}
qualifiers.add(annotation);
}
}
}
if (qualifiers == null) {
qualifiers = Collections.emptySet();
}
var adapter = moshi.adapter(componentType, qualifiers);
MethodHandle accessor;
try {
accessor = lookup.unreflect(component.getAccessor());
} catch (IllegalAccessException e) {
throw new AssertionError(e);
}
var componentBinding = new ComponentBinding<>(name, jsonName, adapter, accessor);
var replaced = bindings.put(jsonName, componentBinding);
if (replaced != null) {
throw new IllegalArgumentException(
"Conflicting components:\n"
+ " "
+ replaced.name
+ "\n"
+ " "
+ componentBinding.name);
}
}
MethodHandle constructor;
try {
constructor = lookup.findConstructor(rawType, methodType(void.class, constructorParams));
} catch (NoSuchMethodException | IllegalAccessException e) {
throw new AssertionError(e);
}
return new RecordJsonAdapter<>(constructor, rawType.getSimpleName(), bindings).nullSafe();
};
private static record ComponentBinding<T>(
String name, String jsonName, JsonAdapter<T> adapter, MethodHandle accessor) {}
private final String targetClass;
private final MethodHandle constructor;
private final ComponentBinding<Object>[] componentBindingsArray;
private final JsonReader.Options options;
@SuppressWarnings("ToArrayCallWithZeroLengthArrayArgument")
public RecordJsonAdapter(
MethodHandle constructor,
String targetClass,
Map<String, ComponentBinding<?>> componentBindings) {
this.constructor = constructor;
this.targetClass = targetClass;
//noinspection unchecked
this.componentBindingsArray =
componentBindings.values().toArray(new ComponentBinding[componentBindings.size()]);
this.options =
JsonReader.Options.of(
componentBindings.keySet().toArray(new String[componentBindings.size()]));
}
@Override
public T fromJson(JsonReader reader) throws IOException {
var resultsArray = new Object[componentBindingsArray.length];
reader.beginObject();
while (reader.hasNext()) {
int index = reader.selectName(options);
if (index == -1) {
reader.skipName();
reader.skipValue();
continue;
}
var result = componentBindingsArray[index].adapter.fromJson(reader);
resultsArray[index] = result;
}
reader.endObject();
try {
//noinspection unchecked
return (T) constructor.invokeWithArguments(resultsArray);
} catch (Throwable e) {
if (e instanceof InvocationTargetException ite) {
Throwable cause = ite.getCause();
if (cause instanceof RuntimeException) throw (RuntimeException) cause;
if (cause instanceof Error) throw (Error) cause;
throw new RuntimeException(cause);
} else {
throw new AssertionError(e);
}
}
}
@Override
public void toJson(JsonWriter writer, T value) throws IOException {
writer.beginObject();
for (var binding : componentBindingsArray) {
writer.name(binding.jsonName);
try {
binding.adapter.toJson(writer, binding.accessor.invoke(value));
} catch (Throwable e) {
if (e instanceof InvocationTargetException ite) {
Throwable cause = ite.getCause();
if (cause instanceof RuntimeException) throw (RuntimeException) cause;
if (cause instanceof Error) throw (Error) cause;
throw new RuntimeException(cause);
} else {
throw new AssertionError(e);
}
}
}
writer.endObject();
}
@Override
public String toString() {
return "JsonAdapter(" + targetClass + ")";
}
}

View File

@@ -126,7 +126,7 @@ public final class JsonAdapterTest {
fail();
} catch (JsonDataException expected) {
assertThat(expected).hasMessageThat().isEqualTo("Unexpected null at $[1]");
assertThat(reader.nextNull()).isNull();
assertThat(reader.<Object>nextNull()).isNull();
}
assertThat(toUpperCase.fromJson(reader)).isEqualTo("C");
reader.endArray();

View File

@@ -565,7 +565,7 @@ public final class JsonReaderTest {
assertThat(reader2.peek()).isEqualTo(JsonReader.Token.END_DOCUMENT);
JsonReader reader3 = newReader("null");
assertThat(reader3.nextNull()).isNull();
assertThat(reader3.<Object>nextNull()).isNull();
assertThat(reader3.peek()).isEqualTo(JsonReader.Token.END_DOCUMENT);
JsonReader reader4 = newReader("123");

View File

@@ -59,7 +59,7 @@ public final class JsonValueReaderTest {
assertThat(reader.hasNext()).isTrue();
assertThat(reader.peek()).isEqualTo(JsonReader.Token.NULL);
assertThat(reader.nextNull()).isNull();
assertThat(reader.<Object>nextNull()).isNull();
assertThat(reader.hasNext()).isFalse();
assertThat(reader.peek()).isEqualTo(JsonReader.Token.END_ARRAY);
@@ -103,7 +103,7 @@ public final class JsonValueReaderTest {
assertThat(reader.peek()).isEqualTo(JsonReader.Token.NAME);
assertThat(reader.nextName()).isEqualTo("d");
assertThat(reader.peek()).isEqualTo(JsonReader.Token.NULL);
assertThat(reader.nextNull()).isNull();
assertThat(reader.<Object>nextNull()).isNull();
assertThat(reader.hasNext()).isFalse();
assertThat(reader.peek()).isEqualTo(JsonReader.Token.END_OBJECT);

View File

@@ -152,10 +152,10 @@ public final class JsonValueWriterTest {
public void primitiveIntegerTypesEmitLong() throws Exception {
JsonValueWriter writer = new JsonValueWriter();
writer.beginArray();
writer.value(new Byte(Byte.MIN_VALUE));
writer.value(new Short(Short.MIN_VALUE));
writer.value(new Integer(Integer.MIN_VALUE));
writer.value(new Long(Long.MIN_VALUE));
writer.value(Byte.valueOf(Byte.MIN_VALUE));
writer.value(Short.valueOf(Short.MIN_VALUE));
writer.value(Integer.valueOf(Integer.MIN_VALUE));
writer.value(Long.valueOf(Long.MIN_VALUE));
writer.endArray();
List<Number> numbers =
@@ -167,8 +167,8 @@ public final class JsonValueWriterTest {
public void primitiveFloatingPointTypesEmitDouble() throws Exception {
JsonValueWriter writer = new JsonValueWriter();
writer.beginArray();
writer.value(new Float(0.5f));
writer.value(new Double(0.5d));
writer.value(Float.valueOf(0.5f));
writer.value(Double.valueOf(0.5d));
writer.endArray();
List<Number> numbers = Arrays.<Number>asList(0.5d, 0.5d);

View File

@@ -236,17 +236,17 @@ public final class JsonWriterTest {
JsonWriter writer = factory.newWriter();
writer.beginArray();
try {
writer.value(new Double(Double.NaN));
writer.value(Double.valueOf(Double.NaN));
fail();
} catch (IllegalArgumentException expected) {
}
try {
writer.value(new Double(Double.NEGATIVE_INFINITY));
writer.value(Double.valueOf(Double.NEGATIVE_INFINITY));
fail();
} catch (IllegalArgumentException expected) {
}
try {
writer.value(new Double(Double.POSITIVE_INFINITY));
writer.value(Double.valueOf(Double.POSITIVE_INFINITY));
fail();
} catch (IllegalArgumentException expected) {
}