Skip to content
Open
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ Change Log
* Refuse `j$.*` types from Android library desugaring as platform types.
* In-development snapshots are now published to the Central Portal Snapshots repository at https://central.sonatype.com/repository/maven-snapshots/.
* Fully supports encoding/decoding of value classes in both moshi-kotlin and code gen.
* Note that Moshi does not propagate inlining to JSON. For example: `@JvmInline value class Color(val raw: Int)` is serialized to `{"raw": 12345}`.
* Note that Moshi does not propagate inlining to JSON by default. For example: `@JvmInline value class Color(val raw: Int)` is serialized to `{"raw": 12345}`.
* New `@JsonClass.inline` property to allow inlining single-property JSON classes during encoding/decoding.
* This is particularly useful for value classes.
* For example, a class `@JvmInline value class UserId(val id: Int)` with `inline = true` will serialize as just `123` rather than `{"id": 123}`.

## Upgrading to Moshi 2.x

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ public class AdapterGenerator(
}
}

private val nonTransientProperties = propertyList.filterNot { it.isTransient }
private val nonTransientProperties = propertyList.filterNot { it.isIgnored }
private val className = target.typeName.rawType()
private val visibility = target.visibility
private val typeVariables = target.typeVariables
Expand Down Expand Up @@ -283,7 +283,11 @@ public class AdapterGenerator(
}
}

result.addProperty(optionsProperty)
// For inline types, we don't need the options property since we read the value directly
if (!target.isInline) {
result.addProperty(optionsProperty)
}

for (uniqueAdapter in nonTransientProperties.distinctBy { it.delegateKey }) {
result.addProperty(
uniqueAdapter.delegateKey.generateProperty(
Expand All @@ -296,8 +300,8 @@ public class AdapterGenerator(
}

result.addFunction(generateToStringFun())
result.addFunction(generateFromJsonFun(result))
result.addFunction(generateToJsonFun())
result.addFunction(generateFromJsonFun(target.isInline, result))
result.addFunction(generateToJson(target.isInline))

return result.build()
}
Expand Down Expand Up @@ -330,12 +334,23 @@ public class AdapterGenerator(
.build()
}

private fun generateFromJsonFun(classBuilder: TypeSpec.Builder): FunSpec {
private fun generateFromJsonFun(isInline: Boolean, classBuilder: TypeSpec.Builder): FunSpec {
val result = FunSpec.builder("fromJson")
.addModifiers(KModifier.OVERRIDE)
.addParameter(readerParam)
.returns(originalTypeName)

return if (isInline) {
generateFromJsonInline(result)
} else {
generateFromJsonRegular(classBuilder, result)
}
}

private fun generateFromJsonRegular(
classBuilder: TypeSpec.Builder,
result: FunSpec.Builder,
): FunSpec {
for (property in nonTransientProperties) {
result.addCode("%L", property.generateLocalProperty())
if (property.hasLocalIsPresentName) {
Expand Down Expand Up @@ -363,7 +378,7 @@ public class AdapterGenerator(
if (property.target.parameterIndex in targetConstructorParams) {
continue // Already handled
}
if (property.isTransient) {
if (property.isIgnored) {
continue // We don't care about these outside of constructor parameters
}
components += PropertyOnly(property)
Expand Down Expand Up @@ -421,12 +436,12 @@ public class AdapterGenerator(

for (input in components) {
if (input is ParameterOnly ||
(input is ParameterProperty && input.property.isTransient)
(input is ParameterProperty && input.property.isIgnored)
) {
updateMaskIndexes()
constructorPropertyTypes += input.type.asTypeBlock()
continue
} else if (input is PropertyOnly && input.property.isTransient) {
} else if (input is PropertyOnly && input.property.isIgnored) {
continue
}

Expand Down Expand Up @@ -566,7 +581,7 @@ public class AdapterGenerator(
result.addCode("«%L·%T(", returnOrResultAssignment, originalTypeName)
var localSeparator = "\n"
val paramsToSet = components.filterIsInstance<ParameterProperty>()
.filterNot { it.property.isTransient }
.filterNot { it.property.isIgnored }

// Set all non-transient property parameters
for (input in paramsToSet) {
Expand Down Expand Up @@ -637,7 +652,7 @@ public class AdapterGenerator(
for (input in components.filterIsInstance<ParameterComponent>()) {
result.addCode(separator)
if (useDefaultsConstructor) {
if (input is ParameterOnly || (input is ParameterProperty && input.property.isTransient)) {
if (input is ParameterOnly || (input is ParameterProperty && input.property.isIgnored)) {
// 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.
Expand All @@ -656,7 +671,7 @@ public class AdapterGenerator(
}
if (input is PropertyComponent) {
val property = input.property
if (!property.isTransient && property.isRequired) {
if (!property.isIgnored && property.isRequired) {
result.addMissingPropertyCheck(property, readerParam)
}
}
Expand Down Expand Up @@ -718,35 +733,92 @@ public class AdapterGenerator(
)
}

private fun generateToJsonFun(): FunSpec {
val result = FunSpec.builder("toJson")
private fun generateToJson(isInline: Boolean): FunSpec {
val builder = FunSpec.builder("toJson")
.addModifiers(KModifier.OVERRIDE)
.addParameter(writerParam)
.addParameter(valueParam)

result.beginControlFlow("if (%N == null)", valueParam)
result.addStatement(
return if (isInline) {
generateToJsonInline(builder)
} else {
generateToJsonRegular(builder)
}
}

private fun generateToJsonRegular(builder: FunSpec.Builder): FunSpec {
builder.beginControlFlow("if (%N == null)", valueParam)
builder.addStatement(
"throw·%T(%S)",
NullPointerException::class,
"${valueParam.name} was null! Wrap in .nullSafe() to write nullable values.",
)
result.endControlFlow()
builder.endControlFlow()

result.addStatement("%N.beginObject()", writerParam)
builder.addStatement("%N.beginObject()", writerParam)
nonTransientProperties.forEach { property ->
// We manually put in quotes because we know the jsonName is already escaped
result.addStatement("%N.name(%S)", writerParam, property.jsonName)
result.addStatement(
builder.addStatement("%N.name(%S)", writerParam, property.jsonName)
builder.addStatement(
"%N.toJson(%N, %N.%N)",
nameAllocator[property.delegateKey],
writerParam,
valueParam,
property.name,
)
}
result.addStatement("%N.endObject()", writerParam)
builder.addStatement("%N.endObject()", writerParam)

return result.build()
return builder.build()
}

/** Generates a fromJson function for inline types that reads the value directly. */
private fun generateFromJsonInline(builder: FunSpec.Builder): FunSpec {
val property = nonTransientProperties.single()

// Read the value directly
if (property.delegateKey.nullable) {
builder.addStatement(
"val %N = %N.fromJson(%N)",
property.localName,
nameAllocator[property.delegateKey],
readerParam,
)
} else {
val exception = unexpectedNull(property, readerParam)
builder.addStatement(
"val %N = %N.fromJson(%N) ?: throw·%L",
property.localName,
nameAllocator[property.delegateKey],
readerParam,
exception,
)
}
builder.addStatement("return %T(%N = %N)", originalTypeName, property.name, property.localName)

return builder.build()
}

/** Generates a toJson function for inline types that writes the value directly. */
private fun generateToJsonInline(builder: FunSpec.Builder): FunSpec {
builder.beginControlFlow("if (%N == null)", valueParam)
builder.addStatement(
"throw·%T(%S)",
NullPointerException::class,
"${valueParam.name} was null! Wrap in .nullSafe() to write nullable values.",
)
builder.endControlFlow()

val property = nonTransientProperties.single()
builder.addStatement(
"%N.toJson(%N, %N.%N)",
nameAllocator[property.delegateKey],
writerParam,
valueParam,
property.name,
)

return builder.build()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import com.squareup.kotlinpoet.PropertySpec
public class PropertyGenerator(
public val target: TargetProperty,
public val delegateKey: DelegateKey,
public val isTransient: Boolean = false,
public val isIgnored: Boolean = false,
) {
public val name: String = target.name
public val jsonName: String = target.jsonName ?: target.name
Expand All @@ -48,7 +48,7 @@ public class PropertyGenerator(
* to an absent value
*/
public val hasLocalIsPresentName: Boolean =
!isTransient && hasDefault && !hasConstructorParameter && delegateKey.nullable
!isIgnored && hasDefault && !hasConstructorParameter && delegateKey.nullable
public val hasConstructorDefault: Boolean = hasDefault && hasConstructorParameter

internal fun allocateNames(nameAllocator: NameAllocator) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public data class TargetType(
val isDataClass: Boolean,
val visibility: KModifier,
val isValueClass: Boolean,
val isInline: Boolean = false,
) {

init {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,12 @@ private class JsonClassSymbolProcessor(environment: SymbolProcessorEnvironment)

if (!jsonClassAnnotation.generateAdapter) continue

val isInline = jsonClassAnnotation.inline

try {
val originatingFile = type.containingFile!!
val adapterGenerator = adapterGenerator(logger, resolver, type) ?: return emptyList()
val adapterGenerator =
adapterGenerator(logger, resolver, type, isInline) ?: return emptyList()
val preparedAdapter = adapterGenerator
.prepare(generateProguardRules) { spec ->
spec.toBuilder()
Expand All @@ -113,8 +116,9 @@ private class JsonClassSymbolProcessor(environment: SymbolProcessorEnvironment)
logger: KSPLogger,
resolver: Resolver,
originalType: KSDeclaration,
isInline: Boolean,
): AdapterGenerator? {
val type = targetType(originalType, resolver, logger) ?: return null
val type = targetType(originalType, resolver, logger, isInline) ?: return null

val properties = mutableMapOf<String, PropertyGenerator>()
for (property in type.properties.values) {
Expand All @@ -124,6 +128,30 @@ private class JsonClassSymbolProcessor(environment: SymbolProcessorEnvironment)
}
}

// Validate inline types have exactly one non-transient property that is not nullable
if (isInline) {
val nonIgnoredBindings = properties.values
.filterNot { it.isIgnored }
if (nonIgnoredBindings.size != 1) {
logger.error(
"@JsonClass with inline = true requires exactly one non-transient property, " +
"but ${originalType.simpleName.asString()} has ${nonIgnoredBindings.size}: " +
"${nonIgnoredBindings.joinToString { it.name }}.",
originalType,
)
return null
}
val inlineProperty = nonIgnoredBindings[0]
if (inlineProperty.delegateKey.nullable) {
logger.error(
"@JsonClass with inline = true requires a non-nullable property, " +
"but ${originalType.simpleName.asString()}.${inlineProperty.name} is nullable.",
originalType,
)
return null
}
}

for ((name, parameter) in type.constructor.parameters) {
if (type.properties[parameter.name] == null && !parameter.hasDefault) {
// TODO would be nice if we could pass the parameter node directly?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ import com.squareup.moshi.kotlin.codegen.api.TargetType
import com.squareup.moshi.kotlin.codegen.api.unwrapTypeAlias

/** Returns a target type for [type] or null if it cannot be used with code gen. */
internal fun targetType(type: KSDeclaration, resolver: Resolver, logger: KSPLogger): TargetType? {
internal fun targetType(
type: KSDeclaration,
resolver: Resolver,
logger: KSPLogger,
isInline: Boolean = false,
): TargetType? {
if (type !is KSClassDeclaration) {
logger.error(
"@JsonClass can't be applied to ${type.qualifiedName?.asString()}: must be a Kotlin class",
Expand Down Expand Up @@ -157,6 +162,7 @@ internal fun targetType(type: KSDeclaration, resolver: Resolver, logger: KSPLogg
isDataClass = Modifier.DATA in type.modifiers,
visibility = resolvedVisibility,
isValueClass = Modifier.VALUE in type.modifiers,
isInline = isInline,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,48 @@ class JsonClassSymbolProcessorTest {
assertThat(result.messages).contains("Error preparing ElementEnvelope")
}

@Test
fun inlineClassWithMultiplePropertiesFails() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice

val result = compile(
kotlin(
"source.kt",
"""
package test
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true, inline = true)
class MultipleProperties(val a: Int, val b: Int)
""",
),
)
assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR)
assertThat(result.messages).contains(
"@JsonClass with inline = true requires exactly one non-transient property, but " +
"MultipleProperties has 2: a, b.",
)
}

@Test
fun inlineClassWithNullablePropertyFails() {
val result = compile(
kotlin(
"source.kt",
"""
package test
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true, inline = true)
class NullableProperty(val a: Int?)
""",
),
)
assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR)
assertThat(result.messages).contains(
"@JsonClass with inline = true requires a non-nullable property, " +
"but NullableProperty.a is nullable.",
)
}

@Test
fun `TypeAliases with the same backing type should share the same adapter`() {
val result = compile(
Expand Down
Loading