Invoke defaults constructor in kotlin code gen (#896)

* Add Util#invokeDefaultConstructor

* Add defaultPrimitiveValue

This will be used to initialize required properties for later invocation of the default constructor

* Move isTransient into PropertyGenerator

We will need this in order to know how to invoke constructors even if they have transient parameters

* Add notion of hasLocalIsPresentName to PropertyGenerator

* Switch to using invokeDefaultConstructor for any default property types

* Add code gen versions of default constructor test

* Fix mismatched names

* Use Arrays.copyOf

* Unwrap InvocationTargetException

* Use name allocator

* Rename createMask to createDefaultValuesParametersMask, use it directly

* Opportunistically clean up result variable holder

Only needs to be made if we have non-parameter instances, otherwise we can just return directly

* Fix mask name

* Remove unnecessary mod

* Switch to local lazily-initialized constructor reference

Not working because of some issue in kotlinpoet I don't understand

* Fix named usage

* Clean up debugging dots

* Add proguard/R8 rule for keeping defaults constructor in targets

* Make constructor lookup property private

* Add another defensive dot

* Rework invokeDefaultConstructor to accept vararg args

A little more idiomatic

* Update proguard rules
This commit is contained in:
Zac Sweers
2019-09-08 17:24:25 -04:00
committed by GitHub
parent 711de52ae1
commit 329d0e14b0
7 changed files with 344 additions and 72 deletions

View File

@@ -29,24 +29,28 @@ import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.TypeVariableName
import com.squareup.kotlinpoet.asClassName
import com.squareup.kotlinpoet.asTypeName
import com.squareup.kotlinpoet.joinToCode
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.Moshi
import me.eugeniomarletti.kotlin.metadata.isDataClass
import com.squareup.moshi.internal.Util
import me.eugeniomarletti.kotlin.metadata.shadow.metadata.ProtoBuf.Visibility
import me.eugeniomarletti.kotlin.metadata.visibility
import java.lang.reflect.Constructor
import java.lang.reflect.Type
import javax.lang.model.element.TypeElement
private val MOSHI_UTIL = Util::class.asClassName()
/** Generates a JSON adapter for a target type. */
internal class AdapterGenerator(
target: TargetType,
private val propertyList: List<PropertyGenerator>
) {
private val nonTransientProperties = propertyList.filterNot { it.isTransient }
private val className = target.name
private val isDataClass = target.proto.isDataClass
private val visibility = target.proto.visibility!!
private val typeVariables = target.typeVariables
@@ -74,19 +78,29 @@ internal class AdapterGenerator(
nameAllocator.newName("value"),
originalTypeName.copy(nullable = true))
.build()
private val jsonAdapterTypeName = JsonAdapter::class.asClassName().parameterizedBy(originalTypeName)
private val jsonAdapterTypeName = JsonAdapter::class.asClassName().parameterizedBy(
originalTypeName)
// selectName() API setup
private val optionsProperty = PropertySpec.builder(
nameAllocator.newName("options"), JsonReader.Options::class.asTypeName(),
KModifier.PRIVATE)
.initializer("%T.of(${propertyList.joinToString(", ") {
.initializer("%T.of(${nonTransientProperties.joinToString(", ") {
CodeBlock.of("%S", it.jsonName).toString()
}})", JsonReader.Options::class.asTypeName())
.build()
private val constructorProperty = PropertySpec.builder(
nameAllocator.newName("constructorRef"),
Constructor::class.asClassName().parameterizedBy(originalTypeName).copy(nullable = true),
KModifier.PRIVATE)
.addAnnotation(Volatile::class)
.mutable(true)
.initializer("null")
.build()
fun generateFile(generatedOption: TypeElement?): FileSpec {
for (property in propertyList) {
for (property in nonTransientProperties) {
property.allocateNames(nameAllocator)
}
@@ -129,7 +143,8 @@ internal class AdapterGenerator(
}
result.addProperty(optionsProperty)
for (uniqueAdapter in propertyList.distinctBy { it.delegateKey }) {
result.addProperty(constructorProperty)
for (uniqueAdapter in nonTransientProperties.distinctBy { it.delegateKey }) {
result.addProperty(uniqueAdapter.delegateKey.generateProperty(
nameAllocator, typeRenderer, moshiParam, uniqueAdapter.name))
}
@@ -172,16 +187,14 @@ internal class AdapterGenerator(
}
private fun generateFromJsonFun(): FunSpec {
val resultName = nameAllocator.newName("result")
val result = FunSpec.builder("fromJson")
.addModifiers(KModifier.OVERRIDE)
.addParameter(readerParam)
.returns(originalTypeName)
for (property in propertyList) {
for (property in nonTransientProperties) {
result.addCode("%L", property.generateLocalProperty())
if (property.differentiateAbsentFromNull) {
if (property.hasLocalIsPresentName) {
result.addCode("%L", property.generateLocalIsPresentProperty())
}
}
@@ -190,8 +203,8 @@ internal class AdapterGenerator(
result.beginControlFlow("while (%N.hasNext())", readerParam)
result.beginControlFlow("when (%N.selectName(%N))", readerParam, optionsProperty)
propertyList.forEachIndexed { index, property ->
if (property.differentiateAbsentFromNull) {
nonTransientProperties.forEachIndexed { index, property ->
if (property.hasLocalIsPresentName) {
result.beginControlFlow("%L -> ", index)
if (property.delegateKey.nullable) {
result.addStatement("%N = %N.fromJson(%N)",
@@ -228,65 +241,81 @@ internal class AdapterGenerator(
result.endControlFlow() // while
result.addStatement("%N.endObject()", readerParam)
// Call the constructor providing only required parameters.
var hasOptionalParameters = false
result.addCode("«var %N = %T(", resultName, originalTypeName)
var separator = "\n"
for (property in propertyList) {
if (!property.hasConstructorParameter) {
continue
var useDefaultsConstructor = false
val parameterProperties = propertyList.asSequence()
.filter { it.hasConstructorParameter }
.onEach {
useDefaultsConstructor = useDefaultsConstructor || it.hasDefault
}
if (property.hasDefault) {
hasOptionalParameters = true
continue
.toList()
val resultName = nameAllocator.newName("result")
val hasNonConstructorProperties = nonTransientProperties.any { !it.hasConstructorParameter }
val returnOrResultAssignment = if (hasNonConstructorProperties) {
// Save the result var for reuse
CodeBlock.of("val %N = ", resultName)
} else {
CodeBlock.of("return·")
}
val maskName = nameAllocator.newName("mask")
val localConstructorName = nameAllocator.newName("localConstructor")
if (useDefaultsConstructor) {
// Dynamic default constructor call
val booleanArrayBlock = parameterProperties.map { param ->
when {
param.isTransient -> CodeBlock.of("false")
param.hasLocalIsPresentName -> CodeBlock.of(param.localIsPresentName)
else -> CodeBlock.of("true")
}
}.joinToCode(", ")
result.addStatement(
"val %1L·= this.%2N ?: %3T.lookupDefaultsConstructor(%4T::class.java).also·{ this.%2N·= it }",
localConstructorName,
constructorProperty,
MOSHI_UTIL,
originalTypeName
)
result.addStatement("val %L = %T.createDefaultValuesParametersMask(%L)",
maskName, MOSHI_UTIL, booleanArrayBlock)
result.addCode(
"«%L%T.invokeDefaultConstructor(%T::class.java, %L, %L, ",
returnOrResultAssignment,
MOSHI_UTIL,
originalTypeName,
localConstructorName,
maskName
)
} else {
// Standard constructor call
result.addCode("«%L%T(", returnOrResultAssignment, originalTypeName)
}
for (property in parameterProperties) {
result.addCode(separator)
if (useDefaultsConstructor) {
if (property.isTransient) {
// We have to use the default primitive for the available type in order for
// invokeDefaultConstructor to properly invoke it. Just using "null" isn't safe because
// the transient type may be a primitive type.
result.addCode(property.target.type.defaultPrimitiveValue())
} else {
result.addCode("%N", property.localName)
}
} else {
result.addCode("%N = %N", property.name, property.localName)
if (property.isRequired) {
}
if (!property.isTransient && property.isRequired) {
result.addCode(" ?: throw·%L", jsonDataException(
"Required property '", property.localName, "' missing at ", readerParam))
}
separator = ",\n"
}
result.addCode("\n", originalTypeName)
// Call either the constructor again, or the copy() method, this time providing any optional
// parameters that we have.
if (hasOptionalParameters) {
if (isDataClass) {
result.addCode("«%1N = %1N.copy(", resultName)
} else {
result.addCode("«%1N = %2T(", resultName, originalTypeName)
}
separator = "\n"
for (property in propertyList) {
if (!property.hasConstructorParameter) {
continue // No constructor parameter for this property.
}
if (isDataClass && !property.hasDefault) {
continue // Property already assigned.
}
result.addCode(separator)
when {
property.differentiateAbsentFromNull -> {
result.addCode("%2N = if (%3N) %4N else %1N.%2N",
resultName, property.name, property.localIsPresentName, property.localName)
}
property.isRequired -> {
result.addCode("%1N = %2N", property.name, property.localName)
}
else -> {
result.addCode("%2N = %3N ?: %1N.%2N", resultName, property.name, property.localName)
}
}
separator = ",\n"
}
result.addCode("»)\n")
}
result.addCode("\n»)\n")
// Assign properties not present in the constructor.
for (property in propertyList) {
for (property in nonTransientProperties) {
if (property.hasConstructorParameter) {
continue // Property already handled.
}
@@ -299,7 +328,9 @@ internal class AdapterGenerator(
}
}
result.addStatement("return %1N", resultName)
if (hasNonConstructorProperties) {
result.addStatement("return·%1N", resultName)
}
return result.build()
}
@@ -315,7 +346,7 @@ internal class AdapterGenerator(
result.endControlFlow()
result.addStatement("%N.beginObject()", writerParam)
propertyList.forEach { property ->
nonTransientProperties.forEach { property ->
result.addStatement("%N.name(%S)", writerParam, property.jsonName)
result.addStatement("%N.toJson(%N, %N.%L)",
nameAllocator[property.delegateKey], writerParam, valueParam, property.name)

View File

@@ -22,7 +22,8 @@ import com.squareup.kotlinpoet.PropertySpec
/** Generates functions to encode and decode a property as JSON. */
internal class PropertyGenerator(
val target: TargetProperty,
val delegateKey: DelegateKey
val delegateKey: DelegateKey,
val isTransient: Boolean = false
) {
val name = target.name
val jsonName = target.jsonName()
@@ -38,6 +39,16 @@ internal class PropertyGenerator(
/** We prefer to use 'null' to mean absent, but for some properties those are distinct. */
val differentiateAbsentFromNull get() = delegateKey.nullable && hasDefault
/**
* IsPresent is required if the following conditions are met:
* - Is not transient
* - Has a default and one of the below
* - Is a constructor property
* - Is a nullable non-constructor property
*/
val hasLocalIsPresentName = !isTransient && hasDefault &&
(hasConstructorParameter || delegateKey.nullable)
fun allocateNames(nameAllocator: NameAllocator) {
localName = nameAllocator.newName(name)
localIsPresentName = nameAllocator.newName("${name}Set")
@@ -46,7 +57,13 @@ internal class PropertyGenerator(
fun generateLocalProperty(): PropertySpec {
return PropertySpec.builder(localName, target.type.copy(nullable = true))
.mutable(true)
.initializer("null")
.apply {
if (hasLocalIsPresentName) {
initializer(target.type.defaultPrimitiveValue())
} else {
initializer("null")
}
}
.build()
}

View File

@@ -80,7 +80,7 @@ internal data class TargetProperty(
Diagnostic.Kind.ERROR, "No default value for transient property ${this}", element)
return null
}
return null // This property is transient and has a default value. Ignore it.
return PropertyGenerator(this, DelegateKey(type, emptyList()), true)
}
if (!isVisible) {

View File

@@ -15,9 +15,20 @@
*/
package com.squareup.moshi.kotlin.codegen
import com.squareup.kotlinpoet.BOOLEAN
import com.squareup.kotlinpoet.BYTE
import com.squareup.kotlinpoet.CHAR
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.CodeBlock
import com.squareup.kotlinpoet.DOUBLE
import com.squareup.kotlinpoet.FLOAT
import com.squareup.kotlinpoet.INT
import com.squareup.kotlinpoet.LONG
import com.squareup.kotlinpoet.ParameterizedTypeName
import com.squareup.kotlinpoet.SHORT
import com.squareup.kotlinpoet.TypeName
import com.squareup.kotlinpoet.UNIT
import com.squareup.kotlinpoet.asTypeName
internal fun TypeName.rawType(): ClassName {
return when (this) {
@@ -26,3 +37,17 @@ internal fun TypeName.rawType(): ClassName {
else -> throw IllegalArgumentException("Cannot get raw type from $this")
}
}
internal fun TypeName.defaultPrimitiveValue(): CodeBlock =
when (this) {
BOOLEAN -> CodeBlock.of("false")
CHAR -> CodeBlock.of("0.toChar()")
BYTE -> CodeBlock.of("0.toByte()")
SHORT -> CodeBlock.of("0.toShort()")
INT -> CodeBlock.of("0")
FLOAT -> CodeBlock.of("0f")
LONG -> CodeBlock.of("0L")
DOUBLE -> CodeBlock.of("0.0")
UNIT, Void::class.asTypeName() -> throw IllegalStateException("Parameter with void or Unit type is illegal")
else -> CodeBlock.of("null")
}

View File

@@ -0,0 +1,91 @@
package com.squareup.moshi.kotlin
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.squareup.moshi.internal.Util
import org.junit.Test
class DefaultConstructorTest {
@Test fun minimal() {
val expected = TestClass("requiredClass")
val args = arrayOf("requiredClass", null, 0, null, 0, 0)
val mask = Util.createDefaultValuesParametersMask(true, false, false, false, false, false)
val constructor = Util.lookupDefaultsConstructor(TestClass::class.java)
val instance = Util.invokeDefaultConstructor(TestClass::class.java, constructor, mask, *args)
check(instance == expected) {
"No match:\nActual : $instance\nExpected: $expected"
}
}
@Test fun allSet() {
val expected = TestClass("requiredClass", "customOptional", 4, "setDynamic", 5, 6)
val args = arrayOf("requiredClass", "customOptional", 4, "setDynamic", 5, 6)
val mask = Util.createDefaultValuesParametersMask(true, true, true, true, true, true)
val constructor = Util.lookupDefaultsConstructor(TestClass::class.java)
val instance = Util.invokeDefaultConstructor(TestClass::class.java, constructor, mask, *args)
check(instance == expected) {
"No match:\nActual : $instance\nExpected: $expected"
}
}
@Test fun customDynamic() {
val expected = TestClass("requiredClass", "customOptional")
val args = arrayOf("requiredClass", "customOptional", 0, null, 0, 0)
val mask = Util.createDefaultValuesParametersMask(true, true, false, false, false, false)
val constructor = Util.lookupDefaultsConstructor(TestClass::class.java)
val instance = Util.invokeDefaultConstructor(TestClass::class.java, constructor, mask, *args)
check(instance == expected) {
"No match:\nActual : $instance\nExpected: $expected"
}
}
@Test fun minimal_codeGen() {
val expected = TestClass("requiredClass")
val json = """{"required":"requiredClass"}"""
val instance = Moshi.Builder().build().adapter<TestClass>(TestClass::class.java)
.fromJson(json)!!
check(instance == expected) {
"No match:\nActual : $instance\nExpected: $expected"
}
}
@Test fun allSet_codeGen() {
val expected = TestClass("requiredClass", "customOptional", 4, "setDynamic", 5, 6)
val json = """{"required":"requiredClass","optional":"customOptional","optional2":4,"dynamicSelfReferenceOptional":"setDynamic","dynamicOptional":5,"dynamicInlineOptional":6}"""
val instance = Moshi.Builder().build().adapter<TestClass>(TestClass::class.java)
.fromJson(json)!!
check(instance == expected) {
"No match:\nActual : $instance\nExpected: $expected"
}
}
@Test fun customDynamic_codeGen() {
val expected = TestClass("requiredClass", "customOptional")
val json = """{"required":"requiredClass","optional":"customOptional"}"""
val instance = Moshi.Builder().build().adapter<TestClass>(TestClass::class.java)
.fromJson(json)!!
check(instance == expected) {
"No match:\nActual : $instance\nExpected: $expected"
}
}
}
@JsonClass(generateAdapter = true)
data class TestClass(
val required: String,
val optional: String = "optional",
val optional2: Int = 2,
val dynamicSelfReferenceOptional: String = required,
val dynamicOptional: Int = createInt(),
val dynamicInlineOptional: Int = createInlineInt()
)
private fun createInt(): Int {
return 3
}
@Suppress("NOTHING_TO_INLINE")
private inline fun createInlineInt(): Int {
return 3
}

View File

@@ -44,6 +44,17 @@ import static com.squareup.moshi.Types.supertypeOf;
public final class Util {
public static final Set<Annotation> NO_ANNOTATIONS = Collections.emptySet();
public static final Type[] EMPTY_TYPE_ARRAY = new Type[] {};
@Nullable private static final Class<?> DEFAULT_CONSTRUCTOR_MARKER;
static {
Class<?> clazz;
try {
clazz = Class.forName("kotlin.jvm.internal.DefaultConstructorMarker");
} catch (ClassNotFoundException e) {
clazz = null;
}
DEFAULT_CONSTRUCTOR_MARKER = clazz;
}
private Util() {
}
@@ -521,4 +532,91 @@ public final class Util {
throw rethrowCause(e);
}
}
/**
* Reflectively looks up the defaults constructor of a kotlin class.
*
* @param targetClass the target kotlin class to instantiate.
* @param <T> the type of {@code targetClass}.
* @return the instantiated {@code targetClass} instance.
* @see #createDefaultValuesParametersMask(boolean...)
*/
public static <T> Constructor<T> lookupDefaultsConstructor(Class<T> targetClass) {
if (DEFAULT_CONSTRUCTOR_MARKER == null) {
throw new IllegalStateException("DefaultConstructorMarker not on classpath. Make sure the "
+ "Kotlin stdlib is on the classpath.");
}
Constructor<T> defaultConstructor = findConstructor(targetClass);
defaultConstructor.setAccessible(true);
return defaultConstructor;
}
/**
* Reflectively invokes the defaults constructor of a kotlin class. This allows indicating which
* arguments are "set" or not, and thus recreate the behavior of named a arguments invocation
* dynamically.
*
* @param targetClass the target kotlin class to instantiate.
* @param defaultsConstructor the target class's defaults constructor in kotlin invoke.
* @param mask an int mask indicating which {@code args} are present.
* @param args the constructor arguments, including "unset" values (set to null or the primitive
* default).
* @param <T> the type of {@code targetClass}.
* @return the instantiated {@code targetClass} instance.
* @see #createDefaultValuesParametersMask(boolean...)
*/
public static <T> T invokeDefaultConstructor(
Class<T> targetClass,
Constructor<T> defaultsConstructor,
int mask,
Object... args) {
Object[] finalArgs = Arrays.copyOf(args, args.length + 2);
finalArgs[finalArgs.length - 2] = mask;
finalArgs[finalArgs.length - 1] = null; // DefaultConstructorMarker param
try {
return defaultsConstructor.newInstance(finalArgs);
} catch (InstantiationException e) {
throw new IllegalStateException("Could not instantiate instance of " + targetClass);
} catch (IllegalAccessException e) {
throw new IllegalStateException("Could not access defaults constructor of " + targetClass);
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof RuntimeException) throw (RuntimeException) cause;
if (cause instanceof Error) throw (Error) cause;
throw new RuntimeException("Could not invoke defaults constructor of " + targetClass, cause);
}
}
private static <T> Constructor<T> findConstructor(Class<T> targetClass) {
for (Constructor<?> constructor : targetClass.getDeclaredConstructors()) {
Class<?>[] paramTypes = constructor.getParameterTypes();
if (paramTypes.length != 0
&& paramTypes[paramTypes.length - 1].equals(DEFAULT_CONSTRUCTOR_MARKER)) {
//noinspection unchecked
return (Constructor<T>) constructor;
}
}
throw new IllegalStateException("No defaults constructor found for " + targetClass);
}
/**
* Creates an mask with bits set to indicate which indices of a default constructor's parameters
* are set.
*
* @param argPresentValues vararg of all present values (set or unset). Max allowable size is 32.
* @return the created mask.
*/
public static int createDefaultValuesParametersMask(boolean... argPresentValues) {
if (argPresentValues.length > 32) {
throw new IllegalArgumentException("Arg present values exceeds max allowable 32.");
}
int mask = 0;
for (int i = 0; i < argPresentValues.length; ++i) {
if (!argPresentValues[i]) {
mask = mask | (1 << i);
}
}
return mask;
}
}

View File

@@ -16,6 +16,16 @@
# The name of @JsonClass types is used to look up the generated adapter.
-keepnames @com.squareup.moshi.JsonClass class *
# Retain generated target class's synthetic defaults constructor and keep DefaultConstructorMarker's
# name. We will look this up reflectively to invoke the type's constructor.
#
# We can't _just_ keep the defaults constructor because Proguard/R8's spec doesn't allow wildcard
# matching preceding parameters.
-keepnames class kotlin.jvm.internal.DefaultConstructorMarker
-keepclassmembers @com.squareup.moshi.JsonClass class * {
<init>(...);
}
# Retain generated JsonAdapters if annotated type is retained.
-if @com.squareup.moshi.JsonClass class *
-keep class <1>JsonAdapter {