From 396342b06b7cd9e09aa5cb2cc8d081289da20ec1 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Tue, 11 Jan 2022 15:39:31 -0500 Subject: [PATCH] Convert RecordJsonAdapter to Kotlin (#1493) --- moshi/build.gradle.kts | 26 ++- moshi/japicmp/build.gradle.kts | 3 +- .../src/main/java/com/squareup/moshi/Moshi.kt | 2 +- .../com/squareup/moshi/RecordJsonAdapter.java | 51 ----- .../com/squareup/moshi/RecordJsonAdapter.kt | 32 +++ .../com/squareup/moshi/RecordJsonAdapter.java | 196 ------------------ .../com/squareup/moshi/RecordJsonAdapter.kt | 176 ++++++++++++++++ 7 files changed, 228 insertions(+), 258 deletions(-) delete mode 100644 moshi/src/main/java/com/squareup/moshi/RecordJsonAdapter.java create mode 100644 moshi/src/main/java/com/squareup/moshi/RecordJsonAdapter.kt delete mode 100644 moshi/src/main/java16/com/squareup/moshi/RecordJsonAdapter.java create mode 100644 moshi/src/main/java16/com/squareup/moshi/RecordJsonAdapter.kt diff --git a/moshi/build.gradle.kts b/moshi/build.gradle.kts index c3d4d88..3d638f2 100644 --- a/moshi/build.gradle.kts +++ b/moshi/build.gradle.kts @@ -2,6 +2,7 @@ import com.vanniktech.maven.publish.JavadocJar.Javadoc import com.vanniktech.maven.publish.KotlinJvm import com.vanniktech.maven.publish.MavenPublishBaseExtension import org.gradle.jvm.tasks.Jar +import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -11,20 +12,27 @@ plugins { } val mainSourceSet by sourceSets.named("main") -val java16 by sourceSets.creating { +val java16: SourceSet by sourceSets.creating { java { srcDir("src/main/java16") } } -tasks.named("compileJava16Java") { - // We use JDK 17 for latest but target 16 for maximum compatibility - javaCompiler.set( - javaToolchains.compilerFor { - languageVersion.set(JavaLanguageVersion.of(17)) - } - ) - options.release.set(16) +// We use JDK 17 for latest but target 16 for maximum compatibility +val service = project.extensions.getByType() +val customLauncher = service.launcherFor { + languageVersion.set(JavaLanguageVersion.of(17)) +} + +tasks.named("compileJava16Kotlin") { + kotlinJavaToolchain.toolchain.use(customLauncher) + kotlinOptions.jvmTarget = "16" +} + +// Grant our java16 sources access to internal APIs in the main source set +kotlin.target.compilations.run { + getByName("java16") + .associateWith(getByName(KotlinCompilation.MAIN_COMPILATION_NAME)) } // Package our actual RecordJsonAdapter from java16 sources in and denote it as an MRJAR diff --git a/moshi/japicmp/build.gradle.kts b/moshi/japicmp/build.gradle.kts index 36ae1cd..c4b2a85 100644 --- a/moshi/japicmp/build.gradle.kts +++ b/moshi/japicmp/build.gradle.kts @@ -29,7 +29,8 @@ val japicmp = tasks.register("japicmp") { "com.squareup.moshi.internal.NonNullJsonAdapter", // Internal. "com.squareup.moshi.internal.NullSafeJsonAdapter", // Internal. "com.squareup.moshi.internal.Util", // Internal. - "com.squareup.moshi.StandardJsonAdapters" // Package-private + "com.squareup.moshi.StandardJsonAdapters", // Package-private + "com.squareup.moshi.RecordJsonAdapter\$ComponentBinding", // Package-private ) methodExcludes = listOf( "com.squareup.moshi.JsonAdapter#indent(java.lang.String)" // Was unintentionally open before diff --git a/moshi/src/main/java/com/squareup/moshi/Moshi.kt b/moshi/src/main/java/com/squareup/moshi/Moshi.kt index 2f2a012..962a7e2 100644 --- a/moshi/src/main/java/com/squareup/moshi/Moshi.kt +++ b/moshi/src/main/java/com/squareup/moshi/Moshi.kt @@ -368,7 +368,7 @@ public class Moshi internal constructor(builder: Builder) { add(CollectionJsonAdapter.Factory) add(MapJsonAdapter.Factory) add(ArrayJsonAdapter.FACTORY) - add(RecordJsonAdapter.FACTORY) + add(RecordJsonAdapter.Factory) add(ClassJsonAdapter.FACTORY) } diff --git a/moshi/src/main/java/com/squareup/moshi/RecordJsonAdapter.java b/moshi/src/main/java/com/squareup/moshi/RecordJsonAdapter.java deleted file mode 100644 index 0f273c0..0000000 --- a/moshi/src/main/java/com/squareup/moshi/RecordJsonAdapter.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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 extends JsonAdapter { - - static final JsonAdapter.Factory FACTORY = - new JsonAdapter.Factory() { - - @Nullable - @Override - public JsonAdapter create( - Type type, Set 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(); - } -} diff --git a/moshi/src/main/java/com/squareup/moshi/RecordJsonAdapter.kt b/moshi/src/main/java/com/squareup/moshi/RecordJsonAdapter.kt new file mode 100644 index 0000000..7e72a04 --- /dev/null +++ b/moshi/src/main/java/com/squareup/moshi/RecordJsonAdapter.kt @@ -0,0 +1,32 @@ +/* + * 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.lang.reflect.Type + +/** + * This is just a simple shim for linking in [StandardJsonAdapters] and swapped with a real + * implementation in Java 16 via MR Jar. + */ +internal class RecordJsonAdapter : JsonAdapter() { + override fun fromJson(reader: JsonReader) = throw AssertionError() + + override fun toJson(writer: JsonWriter, value: T?) = throw AssertionError() + + companion object Factory : JsonAdapter.Factory { + override fun create(type: Type, annotations: Set, moshi: Moshi): JsonAdapter<*>? = null + } +} diff --git a/moshi/src/main/java16/com/squareup/moshi/RecordJsonAdapter.java b/moshi/src/main/java16/com/squareup/moshi/RecordJsonAdapter.java deleted file mode 100644 index 2e77e39..0000000 --- a/moshi/src/main/java16/com/squareup/moshi/RecordJsonAdapter.java +++ /dev/null @@ -1,196 +0,0 @@ -/* - * 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 com.squareup.moshi.internal.Util.rethrowCause; -import static java.lang.invoke.MethodType.methodType; - -import com.squareup.moshi.internal.Util; -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.util.LinkedHashMap; -import java.util.Map; -import java.util.Set; - -/** - * A {@link JsonAdapter} that supports Java {@code record} classes via reflection. - * - *

NOTE: Java records require JDK 16 or higher. - */ -final class RecordJsonAdapter extends JsonAdapter { - - static final JsonAdapter.Factory FACTORY = - new Factory() { - @Override - public JsonAdapter create( - Type type, Set annotations, Moshi 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; - } - - var components = rawType.getRecordComponents(); - var bindings = new LinkedHashMap>(); - var componentRawTypes = new Class[components.length]; - var lookup = MethodHandles.lookup(); - for (int i = 0, componentsLength = components.length; i < componentsLength; i++) { - RecordComponent component = components[i]; - componentRawTypes[i] = component.getType(); - ComponentBinding componentBinding = - createComponentBinding(type, rawType, moshi, lookup, component); - var replaced = bindings.put(componentBinding.jsonName, componentBinding); - if (replaced != null) { - throw new IllegalArgumentException( - "Conflicting components:\n" - + " " - + replaced.componentName - + "\n" - + " " - + componentBinding.componentName); - } - } - - MethodHandle constructor; - try { - constructor = - lookup.findConstructor(rawType, methodType(void.class, componentRawTypes)); - } catch (NoSuchMethodException | IllegalAccessException e) { - throw new AssertionError(e); - } - - return new RecordJsonAdapter<>(constructor, rawType.getSimpleName(), bindings).nullSafe(); - } - - private static ComponentBinding createComponentBinding( - Type type, - Class rawType, - Moshi moshi, - MethodHandles.Lookup lookup, - RecordComponent component) { - var componentName = component.getName(); - var jsonName = Util.jsonName(component, componentName); - - var componentType = Util.resolve(component.getGenericType(), type, rawType); - Set qualifiers = Util.getJsonAnnotations(component); - var adapter = moshi.adapter(componentType, qualifiers); - - MethodHandle accessor; - try { - accessor = lookup.unreflect(component.getAccessor()); - } catch (IllegalAccessException e) { - throw new AssertionError(e); - } - - return new ComponentBinding<>(componentName, jsonName, adapter, accessor); - } - }; - - private record ComponentBinding( - String componentName, String jsonName, JsonAdapter adapter, MethodHandle accessor) {} - - private final String targetClass; - private final MethodHandle constructor; - private final ComponentBinding[] componentBindingsArray; - private final JsonReader.Options options; - - @SuppressWarnings("ToArrayCallWithZeroLengthArrayArgument") - public RecordJsonAdapter( - MethodHandle constructor, - String targetClass, - Map> 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; - } - resultsArray[index] = componentBindingsArray[index].adapter.fromJson(reader); - } - reader.endObject(); - - try { - //noinspection unchecked - return (T) constructor.invokeWithArguments(resultsArray); - } catch (InvocationTargetException e) { - throw rethrowCause(e); - } catch (Throwable e) { - // Don't throw a fatal error if it's just an absent primitive. - for (int i = 0, limit = componentBindingsArray.length; i < limit; i++) { - if (resultsArray[i] == null - && componentBindingsArray[i].accessor.type().returnType().isPrimitive()) { - throw Util.missingProperty( - componentBindingsArray[i].componentName, componentBindingsArray[i].jsonName, reader); - } - } - throw new AssertionError(e); - } - } - - @Override - public void toJson(JsonWriter writer, T value) throws IOException { - writer.beginObject(); - - for (var binding : componentBindingsArray) { - writer.name(binding.jsonName); - Object componentValue; - try { - componentValue = binding.accessor.invoke(value); - } catch (InvocationTargetException e) { - throw Util.rethrowCause(e); - } catch (Throwable e) { - throw new AssertionError(e); - } - binding.adapter.toJson(writer, componentValue); - } - - writer.endObject(); - } - - @Override - public String toString() { - return "JsonAdapter(" + targetClass + ")"; - } -} diff --git a/moshi/src/main/java16/com/squareup/moshi/RecordJsonAdapter.kt b/moshi/src/main/java16/com/squareup/moshi/RecordJsonAdapter.kt new file mode 100644 index 0000000..ad3a683 --- /dev/null +++ b/moshi/src/main/java16/com/squareup/moshi/RecordJsonAdapter.kt @@ -0,0 +1,176 @@ +/* + * 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 com.squareup.moshi.internal.jsonAnnotations +import com.squareup.moshi.internal.jsonName +import com.squareup.moshi.internal.knownNotNull +import com.squareup.moshi.internal.missingProperty +import com.squareup.moshi.internal.resolve +import com.squareup.moshi.internal.rethrowCause +import java.lang.invoke.MethodHandle +import java.lang.invoke.MethodHandles +import java.lang.invoke.MethodType.methodType +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.ParameterizedType +import java.lang.reflect.RecordComponent +import java.lang.reflect.Type + +/** + * A [JsonAdapter] that supports Java `record` classes via reflection. + * + * **NOTE:** Java records require JDK 16 or higher. + */ +internal class RecordJsonAdapter( + private val constructor: MethodHandle, + private val targetClass: String, + componentBindings: Map> +) : JsonAdapter() { + + data class ComponentBinding( + val componentName: String, + val jsonName: String, + val adapter: JsonAdapter, + val accessor: MethodHandle + ) + + private val componentBindingsArray = componentBindings.values.toTypedArray() + private val options = JsonReader.Options.of(*componentBindings.keys.toTypedArray()) + + override fun fromJson(reader: JsonReader): T? { + val resultsArray = arrayOfNulls(componentBindingsArray.size) + + reader.beginObject() + while (reader.hasNext()) { + val index = reader.selectName(options) + if (index == -1) { + reader.skipName() + reader.skipValue() + continue + } + resultsArray[index] = componentBindingsArray[index].adapter.fromJson(reader) + } + reader.endObject() + + return try { + @Suppress("UNCHECKED_CAST") + constructor.invokeWithArguments(*resultsArray) as T + } catch (e: InvocationTargetException) { + throw e.rethrowCause() + } catch (e: Throwable) { + // Don't throw a fatal error if it's just an absent primitive. + for (i in componentBindingsArray.indices) { + if (resultsArray[i] == null && componentBindingsArray[i].accessor.type().returnType().isPrimitive) { + throw missingProperty( + propertyName = componentBindingsArray[i].componentName, + jsonName = componentBindingsArray[i].jsonName, + reader = reader + ) + } + } + throw AssertionError(e) + } + } + + override fun toJson(writer: JsonWriter, value: T?) { + writer.beginObject() + + for (binding in componentBindingsArray) { + writer.name(binding.jsonName) + val componentValue = try { + binding.accessor.invoke(value) + } catch (e: InvocationTargetException) { + throw e.rethrowCause() + } catch (e: Throwable) { + throw AssertionError(e) + } + binding.adapter.toJson(writer, componentValue) + } + + writer.endObject() + } + + override fun toString() = "JsonAdapter($targetClass)" + + companion object Factory : JsonAdapter.Factory { + + private val VOID_CLASS = knownNotNull(Void::class.javaPrimitiveType) + + override fun create(type: Type, annotations: Set, moshi: Moshi): JsonAdapter<*>? { + if (annotations.isNotEmpty()) { + return null + } + + if (type !is Class<*> && type !is ParameterizedType) { + return null + } + + val rawType = type.rawType + if (!rawType.isRecord) { + return null + } + + val components = rawType.recordComponents + val bindings = LinkedHashMap>() + val lookup = MethodHandles.lookup() + val componentRawTypes = Array>(components.size) { i -> + val component = components[i] + val componentBinding = + createComponentBinding(type, rawType, moshi, lookup, component) + val replaced = bindings.put(componentBinding.jsonName, componentBinding) + if (replaced != null) { + throw IllegalArgumentException( + "Conflicting components:\n ${replaced.componentName}\n ${componentBinding.componentName}" + ) + } + component.type + } + + val constructor = try { + lookup.findConstructor(rawType, methodType(VOID_CLASS, componentRawTypes)) + } catch (e: NoSuchMethodException) { + throw AssertionError(e) + } catch (e: IllegalAccessException) { + throw AssertionError(e) + } + + return RecordJsonAdapter(constructor, rawType.simpleName, bindings).nullSafe() + } + + private fun createComponentBinding( + type: Type, + rawType: Class<*>, + moshi: Moshi, + lookup: MethodHandles.Lookup, + component: RecordComponent + ): ComponentBinding { + val componentName = component.name + val jsonName = component.jsonName(componentName) + + val componentType = component.genericType.resolve(type, rawType) + val qualifiers = component.jsonAnnotations + val adapter = moshi.adapter(componentType, qualifiers) + + val accessor = try { + lookup.unreflect(component.accessor) + } catch (e: IllegalAccessException) { + throw AssertionError(e) + } + + return ComponentBinding(componentName, jsonName, adapter, accessor) + } + } +}