New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Kotlin Code Gen module #435
Conversation
private fun mavenGeneratedDir(adapterName: String): File { | ||
// Hack since the maven plugin doesn't supply `kapt.kotlin.generated` option | ||
// Bug filed at https://youtrack.jetbrains.com/issue/KT-22783 | ||
val file = filer.createSourceFile(adapterName).toUri().let(::File) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking about this more yesterday and realized that this only needs to be called once. KotlinPoet takes care of the fqcn path, so this is going to return the same file every time.
The only time that might be different is for different source sets (test, main, etc), but I think those will get different processors (please confirm), so that shouldn't matter.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure I follow. Could you elaborate on the "called once" bit? Since we're only calling it "once" per file anyway 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We call it once per file, but we're getting the root source directory from it, so it only needs to be called once per source set. KotlinPoet takes care of the package folders.
optionsCN, | ||
PRIVATE) | ||
.delegate( | ||
"lazy { %T.of(${optionsByIndex.map { it.value.key } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you sure the not very costly operation of creating some constants is worth an anonymous inner class for each MoshiSerializable data class adapter?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not! But creating options does quite a bit under the hood, and was hoping to gather feedback here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Classloading already makes this lazy. The explicitly lazy isn't needed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Big ol' update above.
Questions that need deciding before moving forward: |
Before this PR gets merged I'll release a new version of |
For 1, I would say no. Unless I'm misunderstanding the |
toString - 36522bc default value handling - Agreed. Talked with Jesse offline too. 881d446 Remove custom names test, per talking with Jesse we'll just go with simple Decided against configuration for primitive adapters - fb7e514 Just leaves this bit:
|
About 5, my personal opinion is: absolutely 👍 |
Good enough for me! I'll work on that. Was maybe a bit too excited about using local |
They're implemented the same way so it doesn't really matter. |
Implemented tracking missing props in 3fd6614 This makes all nullable handling for local properties the same, and removes defaults for primitives in the process. It simplifies the handling a lot, and leans on kotlin language features to take care of null handling (null checking and then throwing the lazily evaluated list of missing properties). One minor change from what kotshi does - this reports the serialized name in the missing properties, not the property name. We could look at supporting this though if we want. It also throws JsonDataException rather than NPEs. Result is a read block looks like this: var string_: String? = null
var nullableString: String? = null
var integer: Int? = null
var nullableInt: Int? = null
var isBoolean: Boolean? = null
var isNullableBoolean: Boolean? = null
var aShort: Short? = null
var nullableShort: Short? = null
var aByte: Byte? = null
var nullableByte: Byte? = null
var aChar: Char? = null
var nullableChar: Char? = null
var list: List<String>? = null
var nestedList: List<Map<String, Set<String>>>? = null
var abstractProperty: String? = null
var customName: String? = null
var annotated: String? = null
var anotherAnnotated: String? = null
var genericClass: GenericClass<String, List<String>>? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.selectName(OPTIONS)) {
0 -> string_ = string_Adapter.fromJson(reader)
1 -> nullableString = string_nullable_Adapter.fromJson(reader)
2 -> integer = int_Adapter.fromJson(reader)
3 -> nullableInt = int_nullable_Adapter.fromJson(reader)
4 -> isBoolean = boolean_Adapter.fromJson(reader)
5 -> isNullableBoolean = boolean_nullable_Adapter.fromJson(reader)
6 -> aShort = short_Adapter.fromJson(reader)
7 -> nullableShort = short_nullable_Adapter.fromJson(reader)
8 -> aByte = byte_Adapter.fromJson(reader)
9 -> nullableByte = byte_nullable_Adapter.fromJson(reader)
10 -> aChar = char_Adapter.fromJson(reader)
11 -> nullableChar = char_nullable_Adapter.fromJson(reader)
12 -> list = list__string_Adapter.fromJson(reader)
13 -> nestedList = list__map__string_set__string_Adapter.fromJson(reader)
14 -> abstractProperty = string_Adapter.fromJson(reader)
15 -> customName = string_Adapter.fromJson(reader)
16 -> annotated = string_Adapter.fromJson(reader)
17 -> anotherAnnotated = string_Adapter.fromJson(reader)
18 -> genericClass = genericClass__string_list__string_Adapter.fromJson(reader)
-1 -> {
// Unknown name, skip it
reader.nextName()
reader.skipValue()
}
}
}
reader.endObject()
val missingArguments: () -> JsonDataException = {
NAMES.filterIndexed { index, _ ->
when (index) {
0 -> string_ == null
2 -> integer == null
4 -> isBoolean == null
6 -> aShort == null
8 -> aByte == null
10 -> aChar == null
12 -> list == null
13 -> nestedList == null
14 -> abstractProperty == null
15 -> customName == null
16 -> annotated == null
17 -> anotherAnnotated == null
18 -> genericClass == null
else -> false
}
}.let { JsonDataException("The following required properties were missing: ${it.joinToString()}") }
}
if (string_ == null) {
throw missingArguments()
}
if (integer == null) {
throw missingArguments()
}
if (isBoolean == null) {
throw missingArguments()
}
if (aShort == null) {
throw missingArguments()
}
if (aByte == null) {
throw missingArguments()
}
if (aChar == null) {
throw missingArguments()
}
if (list == null) {
throw missingArguments()
}
if (nestedList == null) {
throw missingArguments()
}
if (abstractProperty == null) {
throw missingArguments()
}
if (customName == null) {
throw missingArguments()
}
if (annotated == null) {
throw missingArguments()
}
if (anotherAnnotated == null) {
throw missingArguments()
}
if (genericClass == null) {
throw missingArguments()
}
return TestClass(string = string_,
nullableString = nullableString,
integer = integer,
nullableInt = nullableInt,
isBoolean = isBoolean,
isNullableBoolean = isNullableBoolean,
aShort = aShort,
nullableShort = nullableShort,
aByte = aByte,
nullableByte = nullableByte,
aChar = aChar,
nullableChar = nullableChar,
list = list,
nestedList = nestedList,
abstractProperty = abstractProperty,
customName = customName,
annotated = annotated,
anotherAnnotated = anotherAnnotated,
genericClass = genericClass) |
Implemented JsonQualifier support in e8427be! I made Resulting code looks like this: private val string_Adapter_for_WrappedInArray_WrappedInObject_Adapter: JsonAdapter<String> =
moshi.adapter<String>(String::class.java, setOf(Types.createJsonQualifierImplementation(WrappedInArray::class.java), Types.createJsonQualifierImplementation(WrappedInObject::class.java))).nullSafe() Pretty happy with the result - no extra methods, passes the kotshi tests. It does rely on proxies, but I'm not sure there's a way around that right now since kotlin classes don't support subclassing and the only alternative I can think of is to reflectively go yank them out of the constructor params at runtime 😷 . Not sure what the CI failure is, maybe travis goofed? Works for me locally 🤔 |
Still no luck!
None of these are data classes tests right now, which is the only thing this supports right now
|
||
val originalTypeName = originalElement.asType().asTypeName() | ||
val moshiName = "moshi".allocate() | ||
val moshiParam = ParameterSpec.builder(moshiName, Moshi::class.asClassName()).build() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
asClassName()
looks redundant, you should be able to simply pass Moshi::class
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
} | ||
} | ||
.beginControlFlow("-1 ->") | ||
.addCode("// Unknown name, skip it.\n") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
addComment()
can add \\
and \n
for you
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ahh good catch! 7525f89
@Target(CLASS) | ||
annotation class MoshiSerializable | ||
|
||
class MoshiSerializableFactory : JsonAdapter.Factory { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not make this an object
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I asked higher up about whether or not to make this class a singleton (lazy or otherwise), and we went with just making this instantiable. I don't really have an opinion though!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It’s consistent with what we did for reflection.
KotlinJsonAdapterFactory
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
any particular reason to [separate the Moshi.adapter change]
It's a new API that affects users outside of this change. Project watchers with use cases or opinions who might not be following this change might be interested in the general changes to Moshi's API. Also, this is a huge PR.
But, no big deal in this case.
I'll take a look a the generated code next week and post thoughts on the code gen stuff!
for (Class<? extends Annotation> annotationType : annotationTypes) { | ||
annotations.add(Types.createJsonQualifierImplementation(annotationType)); | ||
} | ||
return adapter(type, annotations); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Collections.unmodifiableSet
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@CheckReturnValue | ||
public <T> JsonAdapter<T> adapter(Type type, Class<? extends Annotation>... annotationTypes) { | ||
if (annotationTypes.length == 1) { | ||
return adapter(type, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe return adapter(type, annotationTypes[0])
It's interesting that we know annotationTypes.length
is never 0 because of the existing method signature.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's clever, and saves a bit of code duplication - 5ed2a58
qualifiers.isEmpty() -> "" to emptyArray() | ||
qualifiers.size == 1 -> { | ||
", %${standardArgsSize}T::class.java" to arrayOf( | ||
qualifiers.first().annotationType.asTypeName()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this looks like it will run into ansman/kotshi#60 (same below)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting. Definitely interested in seeing Moshi support that case
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moshi does support it. Both the KotlinJsonAdapter and the ClassJsonAdapter hand off the annotation instances to adapter(Type, Set...)
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
that's via reflectively pulling off the annotation instances, whereas the idea here was to avoid that since we know them upfront
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This PR does adapter(Type, Class<?>
). This will create a new annotation instance that loses the declared annotation elements.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see what you mean, but I'm confused on what you mean by Moshi does support it
above? One way I could envision that case working here is by reflectively pulling them off the parameters at runtime, as kotlin annotations don't allow for something like AutoAnnotation
since they're final
:/ .
The other, albeit possibly crazy way, is to generate proxy invocation handlers that include the statically defined data (kind of our own inline kotlin-ified autoannotation with proxies)
@@ -70,6 +71,19 @@ | |||
Collections.singleton(Types.createJsonQualifierImplementation(annotationType))); | |||
} | |||
|
|||
@CheckReturnValue | |||
public <T> JsonAdapter<T> adapter(Type type, Class<? extends Annotation>... annotationTypes) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
in light of my comment on needing the JsonQualifier elements, i'm hoping this isn't needed, at least for this PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As in you'd prefer to see this API removed? Or that you're hoping needing jsonqualifier elements support isn't needed for this PR?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm thinking that this API won't be needed because the usage of it won't be the intended behavior (honoring the annotation elements).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't quite follow. Why doesn't this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
moving to #435 (comment)
Technically more correct since we're defining these
Yeah I didn't intend for this PR to become this big (wanted to keep it to the processor and iterate on integration tests), but with the offline requests to add both the kotlinjsonadapter tests and kotshi tests, I don't see how it could have stayed small. Have tried to keep the commits small and digestible. |
If the type is a parameterized type, then we know they'll have the two-arg constructor. This way we don't always try and fail the single arg constructor on parameterized types
in toJson, there's a check for non-null types: if (value == null) {
throw NullPointerException("value was null! Wrap in .nullSafe() to write nullable values.")
} should there be a symmetric check in fromJson (peek on the reader for null)? In return DataClassesTest.DefaultValues(foo = foo ?: throw JsonDataException("Required property 'foo' missing at ${reader.path}"))
.let {
it.copy(bar = bar ?: it.bar,
nullableBar = if (nullableBarSet) nullableBar else it.nullableBar,
bazList = bazList ?: it.bazList)
} Do we need the In |
if (!rawType.isAnnotationPresent(MoshiSerializable::class.java)) { | ||
return null | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
throw an error if this is not a Kotlin class (doesn't have that metadata annotation)?
* If you don't want this though, you can use the runtime [MoshiSerializable] factory implementation. | ||
*/ | ||
@AutoService(Processor::class) | ||
class MoshiKotlinCodeGenProcessor : KotlinAbstractProcessor(), KotlinMetadataUtils { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why the inheritance instead of top-level utility functions? can we copy the utilities we need?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mostly adopted from the example in the repo, but I'm not exactly in favor of just copying in the utilities we need (implicit fork, etc). I don't really see an issue with this if we're ok with otherwise using AbstractProcessor()
as a base class
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don’t like that this implements the KotlinMetadataUtils
interface. It adds that type to this type’s public API. This isn’t a dealbreaker here because this isn’t an API type, but it’s still bad to have APIs that impose themselves on API signatures.
It’s especially awkward for Kotlin since there are already good ways to expose this kind of behavior. For example, why even have the interface for extension functions? Seems like something @Takhion might be able to fix.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Ideally we fix in follow-up to this PR.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I agree, we can clean it up in a followup. Will talk with @Takhion
|
||
@Retention(RUNTIME) | ||
@Target(CLASS) | ||
annotation class MoshiSerializable |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it'd still be neat to generate a JsonAdapter.Factory instead of using this class. or was there an explicit reason not to?
Trying to unpack the larger comment above and answering inline, let me know if I've messed up the groupings.
There was, but not anymore after dea9c0b. Also - the code snippet seems to be from
This went through a few iterations in the PR, but the most recent one we landed on after @swankjesse's past pass was to read everything into nullable local variables, then throw eagerly in the event of null/absent values for require properties in 8eb5bb4
Yes. Worked on this with @rharter and @swankjesse a fair bit before the PR, it's basically a clever way to "read" the default values from the object since we can't really read them at compile time and inline them.
I agree! Wasn't sure how to differentiate type params in the simplified names, suggestions welcome :). General trend from the other naming cleanups was to move as close as possible to camel casing and general readability.
(I oddly can't respond to this one inline on github 🤔) Also - assuming you meant to comment on the factory and not the annotation? We definitely need the annotation in some form as a sentinel to the annotation processor. |
This is a tiny optimization to make type aliases (which did already work) reuse adapter properties if they already exist for the backing type. What this means is that if you have: typealias Foo = String and properties foo: Foo bar: String you'll only get one adapter property field for String, and both will use it
<repository> | ||
<id>jcenter</id> | ||
<url>https://jcenter.bintray.com/</url> | ||
</repository> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added a tracking issue: Takhion/kotlin-metadata#5
/** | ||
* An annotation processor that reads Kotlin data classes and generates Moshi JsonAdapters for them. | ||
* This generates Kotlin code, and understands basic Kotlin language features like default values | ||
* and companion objects. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nice docs
* If you don't want this though, you can use the runtime [MoshiSerializable] factory implementation. | ||
*/ | ||
@AutoService(Processor::class) | ||
class MoshiKotlinCodeGenProcessor : KotlinAbstractProcessor(), KotlinMetadataUtils { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don’t like that this implements the KotlinMetadataUtils
interface. It adds that type to this type’s public API. This isn’t a dealbreaker here because this isn’t an API type, but it’s still bad to have APIs that impose themselves on API signatures.
It’s especially awkward for Kotlin since there are already good ways to expose this kind of behavior. For example, why even have the interface for extension functions? Seems like something @Takhion might be able to fix.
* If you don't want this though, you can use the runtime [MoshiSerializable] factory implementation. | ||
*/ | ||
@AutoService(Processor::class) | ||
class MoshiKotlinCodeGenProcessor : KotlinAbstractProcessor(), KotlinMetadataUtils { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Ideally we fix in follow-up to this PR.)
override fun getSupportedSourceVersion(): SourceVersion = SourceVersion.latest() | ||
|
||
override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean { | ||
val annotationElement = elementUtils.getTypeElement(annotationName) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it’s awkward how elementUtils is wired in. If we wanted a minimal way to go from MoshiSerializable::class to the corresponding element, it would be neater to have a function that does just that:
fun ProcessingEnvironment.typeElement(kclass: KClass) = ...
Can do in follow-up
inner class InnerClass(val a: Int) | ||
|
||
@Ignore @Test fun localClassesNotSupported() { | ||
class LocalClass(val a: Int) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@MoshiSerializable ?
} | ||
} | ||
|
||
@Ignore @Test fun objectDeclarationsNotSupported() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oooh neat
import com.squareup.moshi.MoshiSerializable | ||
|
||
@MoshiSerializable | ||
data class ClassWithWeirdNames( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
love it
data class WithCompanionProperty(val v: String?) | ||
|
||
@MoshiSerializable | ||
data class WithStaticProperty(val v: String?) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
huh?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what’s the static property?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Might have been a vestige of the kotshi default values API
@@ -0,0 +1,7 @@ | |||
package com.squareup.moshi.kotshi |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I’m reluctant to merge this unless it’s a contribution from the original author. CLA stuff.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ansman ?
This is an initial implementation of a code gen module. Originally from https://github.com/hzsweers/CatchUp/tree/master/tooling/moshkt
There's three main components:
moshi-kotlin-codegen
- the actually processor implementationintegration-test
- An integration test. This contains ignored KotlinJsonAdapter tests (Jesse mentioned wanting them to be able to pass the same tests is the long term goal, so this gives something to chip at) and some basic data classes tests.moshi-kotlin-codegen-runtime
- A runtime artifact with the@MoshiSerializable
annotation and its correspondingMoshiSerializableJsonAdapterFactory
. The factory usage is optional, as one could write a factory generator if they wanted to.Supported:
@Json
annotationsjsonAdapter()
function onto itUnimplemented:
For data classes, it's been working swimmingly in CatchUp as well as @rharter's codebases. Code itself could probably use some cleaning up (there's plenty of TODOs left in), but its output seems to be working well so far.
CC @Takhion
Example:
Generates