From fad416612ebeab4012c0e23a4dbbb3ceb374f110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Quenaudon?= Date: Mon, 27 Apr 2026 14:41:33 +0100 Subject: [PATCH 1/5] WIP SealedClass for oneofs --- wire-compiler/api/wire-compiler.api | 10 +- .../java/com/squareup/wire/schema/Target.kt | 5 + .../api/wire-kotlin-generator.api | 21 +- .../squareup/wire/kotlin/KotlinGenerator.kt | 242 +++++++++++++++--- .../wire/kotlin/KotlinSchemaHandler.kt | 4 + .../com/squareup/wire/kotlin/OneofMode.kt | 47 ++++ .../wire/kotlin/KotlinGeneratorTest.kt | 200 +++++++++++++++ .../kotlin/KotlinWithProfilesGenerator.kt | 2 + 8 files changed, 490 insertions(+), 41 deletions(-) create mode 100644 wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/OneofMode.kt diff --git a/wire-compiler/api/wire-compiler.api b/wire-compiler/api/wire-compiler.api index 5b381b2d6c..2f0aaa91af 100644 --- a/wire-compiler/api/wire-compiler.api +++ b/wire-compiler/api/wire-compiler.api @@ -121,8 +121,8 @@ public final class com/squareup/wire/schema/JavaTarget : com/squareup/wire/schem } public final class com/squareup/wire/schema/KotlinTarget : com/squareup/wire/schema/Target { - public fun (Ljava/util/List;Ljava/util/List;ZLjava/lang/String;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ZILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;ZZZZ)V - public synthetic fun (Ljava/util/List;Ljava/util/List;ZLjava/lang/String;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ZILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;ZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/util/List;Ljava/util/List;ZLjava/lang/String;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ZILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;Lcom/squareup/wire/kotlin/OneofMode;ZZZZ)V + public synthetic fun (Ljava/util/List;Ljava/util/List;ZLjava/lang/String;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ZILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;Lcom/squareup/wire/kotlin/OneofMode;ZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/util/List; public final fun component10 ()Lcom/squareup/wire/kotlin/RpcRole; public final fun component11 ()Z @@ -131,6 +131,7 @@ public final class com/squareup/wire/schema/KotlinTarget : com/squareup/wire/sch public final fun component14 ()Z public final fun component15 ()Z public final fun component16 ()Lcom/squareup/wire/kotlin/EnumMode; + public final fun component17 ()Lcom/squareup/wire/kotlin/OneofMode; public final fun component2 ()Ljava/util/List; public final fun component3 ()Z public final fun component4 ()Ljava/lang/String; @@ -139,8 +140,8 @@ public final class com/squareup/wire/schema/KotlinTarget : com/squareup/wire/sch public final fun component7 ()Z public final fun component8 ()Z public final fun component9 ()Lcom/squareup/wire/kotlin/RpcCallStyle; - public final fun copy (Ljava/util/List;Ljava/util/List;ZLjava/lang/String;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ZILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;ZZZZ)Lcom/squareup/wire/schema/KotlinTarget; - public static synthetic fun copy$default (Lcom/squareup/wire/schema/KotlinTarget;Ljava/util/List;Ljava/util/List;ZLjava/lang/String;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ZILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;ZZZZILjava/lang/Object;)Lcom/squareup/wire/schema/KotlinTarget; + public final fun copy (Ljava/util/List;Ljava/util/List;ZLjava/lang/String;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ZILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;Lcom/squareup/wire/kotlin/OneofMode;ZZZZ)Lcom/squareup/wire/schema/KotlinTarget; + public static synthetic fun copy$default (Lcom/squareup/wire/schema/KotlinTarget;Ljava/util/List;Ljava/util/List;ZLjava/lang/String;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ZILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;Lcom/squareup/wire/kotlin/OneofMode;ZZZZILjava/lang/Object;)Lcom/squareup/wire/schema/KotlinTarget; public fun copyTarget (Ljava/util/List;Ljava/util/List;ZLjava/lang/String;)Lcom/squareup/wire/schema/Target; public fun equals (Ljava/lang/Object;)Z public final fun getAndroid ()Z @@ -155,6 +156,7 @@ public final class com/squareup/wire/schema/KotlinTarget : com/squareup/wire/sch public fun getIncludes ()Ljava/util/List; public final fun getJavaInterop ()Z public final fun getNameSuffix ()Ljava/lang/String; + public final fun getOneofMode ()Lcom/squareup/wire/kotlin/OneofMode; public fun getOutDirectory ()Ljava/lang/String; public final fun getRpcCallStyle ()Lcom/squareup/wire/kotlin/RpcCallStyle; public final fun getRpcRole ()Lcom/squareup/wire/kotlin/RpcRole; diff --git a/wire-compiler/src/main/java/com/squareup/wire/schema/Target.kt b/wire-compiler/src/main/java/com/squareup/wire/schema/Target.kt index 80d24ecf64..3e5b6e86e1 100644 --- a/wire-compiler/src/main/java/com/squareup/wire/schema/Target.kt +++ b/wire-compiler/src/main/java/com/squareup/wire/schema/Target.kt @@ -18,6 +18,7 @@ package com.squareup.wire.schema import com.squareup.wire.java.JavaSchemaHandler import com.squareup.wire.kotlin.EnumMode import com.squareup.wire.kotlin.KotlinSchemaHandler +import com.squareup.wire.kotlin.OneofMode import com.squareup.wire.kotlin.RpcCallStyle import com.squareup.wire.kotlin.RpcRole import com.squareup.wire.swift.SwiftSchemaHandler @@ -133,6 +134,9 @@ data class KotlinTarget( /** enum_class or sealed_class. See [EnumMode][com.squareup.wire.kotlin.EnumMode]. */ val enumMode: EnumMode = EnumMode.ENUM_CLASS, + /** legacy, boxed, or sealed_class. See [OneofMode][com.squareup.wire.kotlin.OneofMode]. */ + val oneofMode: OneofMode = OneofMode.LEGACY, + /** * If true, adapters will generate decode functions for `ProtoReader32`. Use this optimization * when targeting Kotlin/JS, where `Long` cursors are inefficient. @@ -174,6 +178,7 @@ data class KotlinTarget( buildersOnly = buildersOnly, escapeKotlinKeywords = escapeKotlinKeywords, enumMode = enumMode, + oneofMode = oneofMode, emitProtoReader32 = emitProtoReader32, mutableTypes = mutableTypes, explicitStreamingCalls = explicitStreamingCalls, diff --git a/wire-kotlin-generator/api/wire-kotlin-generator.api b/wire-kotlin-generator/api/wire-kotlin-generator.api index ab1c4b8927..84d3502476 100644 --- a/wire-kotlin-generator/api/wire-kotlin-generator.api +++ b/wire-kotlin-generator/api/wire-kotlin-generator.api @@ -8,7 +8,7 @@ public final class com/squareup/wire/kotlin/EnumMode : java/lang/Enum { public final class com/squareup/wire/kotlin/KotlinGenerator { public static final field Companion Lcom/squareup/wire/kotlin/KotlinGenerator$Companion; - public synthetic fun (Lcom/squareup/wire/schema/Schema;Ljava/util/Map;Ljava/util/Map;Lcom/squareup/wire/schema/Profile;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;ZZZZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lcom/squareup/wire/schema/Schema;Ljava/util/Map;Ljava/util/Map;Lcom/squareup/wire/schema/Profile;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;Lcom/squareup/wire/kotlin/OneofMode;ZZZZLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun generateOptionType (Lcom/squareup/wire/schema/Extend;Lcom/squareup/wire/schema/Field;)Lcom/squareup/kotlinpoet/TypeSpec; public final fun generateServiceTypeSpecs (Lcom/squareup/wire/schema/Service;Lcom/squareup/wire/schema/Rpc;)Ljava/util/Map; public static synthetic fun generateServiceTypeSpecs$default (Lcom/squareup/wire/kotlin/KotlinGenerator;Lcom/squareup/wire/schema/Service;Lcom/squareup/wire/schema/Rpc;ILjava/lang/Object;)Ljava/util/Map; @@ -17,20 +17,20 @@ public final class com/squareup/wire/kotlin/KotlinGenerator { public static synthetic fun generatedServiceName$default (Lcom/squareup/wire/kotlin/KotlinGenerator;Lcom/squareup/wire/schema/Service;Lcom/squareup/wire/schema/Rpc;ZILjava/lang/Object;)Lcom/squareup/kotlinpoet/ClassName; public final fun generatedTypeName (Lcom/squareup/wire/schema/ProtoMember;)Lcom/squareup/kotlinpoet/ClassName; public final fun generatedTypeName (Lcom/squareup/wire/schema/Type;)Lcom/squareup/kotlinpoet/ClassName; - public static final fun get (Lcom/squareup/wire/schema/Schema;Lcom/squareup/wire/schema/Profile;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;ZZZZ)Lcom/squareup/wire/kotlin/KotlinGenerator; + public static final fun get (Lcom/squareup/wire/schema/Schema;Lcom/squareup/wire/schema/Profile;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;Lcom/squareup/wire/kotlin/OneofMode;ZZZZ)Lcom/squareup/wire/kotlin/KotlinGenerator; public final fun getSchema ()Lcom/squareup/wire/schema/Schema; } public final class com/squareup/wire/kotlin/KotlinGenerator$Companion { public final fun builtInType (Lcom/squareup/wire/schema/ProtoType;)Z - public final fun get (Lcom/squareup/wire/schema/Schema;Lcom/squareup/wire/schema/Profile;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;ZZZZ)Lcom/squareup/wire/kotlin/KotlinGenerator; - public static synthetic fun get$default (Lcom/squareup/wire/kotlin/KotlinGenerator$Companion;Lcom/squareup/wire/schema/Schema;Lcom/squareup/wire/schema/Profile;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;ZZZZILjava/lang/Object;)Lcom/squareup/wire/kotlin/KotlinGenerator; + public final fun get (Lcom/squareup/wire/schema/Schema;Lcom/squareup/wire/schema/Profile;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;Lcom/squareup/wire/kotlin/OneofMode;ZZZZ)Lcom/squareup/wire/kotlin/KotlinGenerator; + public static synthetic fun get$default (Lcom/squareup/wire/kotlin/KotlinGenerator$Companion;Lcom/squareup/wire/schema/Schema;Lcom/squareup/wire/schema/Profile;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;Lcom/squareup/wire/kotlin/OneofMode;ZZZZILjava/lang/Object;)Lcom/squareup/wire/kotlin/KotlinGenerator; } public final class com/squareup/wire/kotlin/KotlinSchemaHandler : com/squareup/wire/schema/SchemaHandler { public static final field Companion Lcom/squareup/wire/kotlin/KotlinSchemaHandler$Companion; - public fun (Ljava/lang/String;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ZILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;ZZZZ)V - public synthetic fun (Ljava/lang/String;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ZILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;ZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ZILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;Lcom/squareup/wire/kotlin/OneofMode;ZZZZ)V + public synthetic fun (Ljava/lang/String;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ZILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;Lcom/squareup/wire/kotlin/OneofMode;ZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getEnumMode ()Lcom/squareup/wire/kotlin/EnumMode; public fun handle (Lcom/squareup/wire/schema/Extend;Lcom/squareup/wire/schema/Field;Lcom/squareup/wire/schema/SchemaHandler$Context;)Lokio/Path; public fun handle (Lcom/squareup/wire/schema/Schema;Lcom/squareup/wire/schema/SchemaHandler$Context;)V @@ -41,6 +41,15 @@ public final class com/squareup/wire/kotlin/KotlinSchemaHandler : com/squareup/w public final class com/squareup/wire/kotlin/KotlinSchemaHandler$Companion { } +public final class com/squareup/wire/kotlin/OneofMode : java/lang/Enum { + public static final field BOXED Lcom/squareup/wire/kotlin/OneofMode; + public static final field LEGACY Lcom/squareup/wire/kotlin/OneofMode; + public static final field SEALED_CLASS Lcom/squareup/wire/kotlin/OneofMode; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/squareup/wire/kotlin/OneofMode; + public static fun values ()[Lcom/squareup/wire/kotlin/OneofMode; +} + public final class com/squareup/wire/kotlin/RpcCallStyle : java/lang/Enum { public static final field BLOCKING Lcom/squareup/wire/kotlin/RpcCallStyle; public static final field SUSPENDING Lcom/squareup/wire/kotlin/RpcCallStyle; diff --git a/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinGenerator.kt b/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinGenerator.kt index 413d0b67ac..74f8d777dc 100644 --- a/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinGenerator.kt +++ b/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinGenerator.kt @@ -84,6 +84,7 @@ import com.squareup.wire.internal.boxedOneOfKeyFieldName import com.squareup.wire.internal.boxedOneOfKeysFieldName import com.squareup.wire.kotlin.EnumMode.ENUM_CLASS import com.squareup.wire.kotlin.EnumMode.SEALED_CLASS +import com.squareup.wire.kotlin.OneofMode import com.squareup.wire.schema.EnclosingType import com.squareup.wire.schema.EnumConstant import com.squareup.wire.schema.EnumType @@ -98,6 +99,7 @@ import com.squareup.wire.schema.Options.Companion.ENUM_VALUE_OPTIONS import com.squareup.wire.schema.Options.Companion.FIELD_OPTIONS import com.squareup.wire.schema.Options.Companion.MESSAGE_OPTIONS import com.squareup.wire.schema.Options.Companion.METHOD_OPTIONS +import com.squareup.wire.schema.Options.Companion.ONEOF_OPTIONS import com.squareup.wire.schema.Options.Companion.SERVICE_OPTIONS import com.squareup.wire.schema.Profile import com.squareup.wire.schema.ProtoFile @@ -138,12 +140,14 @@ class KotlinGenerator private constructor( private val buildersOnly: Boolean, private val escapeKotlinKeywords: Boolean, private val enumMode: EnumMode, + private val oneofMode: OneofMode, private val emitProtoReader32: Boolean, private val mutableTypes: Boolean, private val explicitStreamingCalls: Boolean, private val makeImmutableCopies: Boolean, ) { private val nameAllocatorStore = mutableMapOf() + private val sealedSubclassNameAllocatorStore = mutableMapOf() private val jvmAnnotationPackage: String = if (javaInterOp) "kotlin.jvm" else "com.squareup.wire.internal" private val useJavaInterop: Boolean = javaInterOp || buildersOnly @@ -546,14 +550,16 @@ class KotlinGenerator private constructor( } is OneOf -> { val fieldName = newName(fieldOrOneOf.name, fieldOrOneOf) - val keysFieldName = boxedOneOfKeysFieldName(fieldName) - check(newName(keysFieldName) == keysFieldName) { - "unexpected name collision for keys set of boxed one of, ${fieldOrOneOf.name}" - } newName(boxedOneOfClassName(fieldOrOneOf.name), boxedOneOfClassName(fieldOrOneOf.name)) - fieldOrOneOf.fields.forEach { field -> - val keyFieldName = boxedOneOfKeyFieldName(fieldOrOneOf.name, field.name) - newName(keyFieldName, keyFieldName) + if (oneofMode != OneofMode.SEALED_CLASS) { + val keysFieldName = boxedOneOfKeysFieldName(fieldName) + check(newName(keysFieldName) == keysFieldName) { + "unexpected name collision for keys set of boxed one of, ${fieldOrOneOf.name}" + } + fieldOrOneOf.fields.forEach { field -> + val keyFieldName = boxedOneOfKeyFieldName(fieldOrOneOf.name, field.name) + newName(keyFieldName, keyFieldName) + } } } else -> throw IllegalArgumentException("Unexpected element: $fieldOrOneOf") @@ -642,6 +648,11 @@ class KotlinGenerator private constructor( addOneOfKeys(companionBuilder, oneOf, boxClassName, nameAllocator) } + for (oneOf in type.sealedOneOfs()) { + val sealedClassName = className.nestedClass(nameAllocator[boxedOneOfClassName(oneOf.name)]) + classBuilder.addType(sealedOneOfClass(sealedClassName, oneOf)) + } + companionBuilder.addProperty( PropertySpec.builder("serialVersionUID", LONG, PRIVATE, CONST) .initializer("0L") @@ -1026,6 +1037,16 @@ class KotlinGenerator private constructor( ), ) } + for (sealedOneOf in type.sealedOneOfs()) { + builder.addFunction( + boxOneOfBuilderSetter( + type, + sealedOneOf, + nameAllocator, + builderClass, + ), + ) + } val buildFunction = FunSpec.builder("build") .addModifiers(OVERRIDE) @@ -1707,8 +1728,28 @@ class KotlinGenerator private constructor( } is OneOf -> { val fieldName = localNameAllocator[fieldOrOneOf] - add("if (value.%1N != %2L) ", fieldName, "null") - addStatement("%N += value.%N.encodedSizeWithTag()", sizeName, fieldName) + if (fieldOrOneOf in message.sealedOneOfs()) { + val sealedClassName = (message.typeName as ClassName) + .nestedClass(localNameAllocator[boxedOneOfClassName(fieldOrOneOf.name)]) + val subclassNameAllocator = sealedSubclassNameAllocator(fieldOrOneOf) + beginControlFlow("when (val %N = value.%N)", fieldName, fieldName) + for (field in fieldOrOneOf.fields) { + val subclassType = sealedClassName.nestedClass(subclassNameAllocator[field]) + addStatement( + "is %T -> %N += %L.encodedSizeWithTag(%L, %N.value)", + subclassType, + sizeName, + adapterFor(field), + field.tag, + fieldName, + ) + } + addStatement("null -> {}") + endControlFlow() + } else { + add("if (value.%1N != %2L) ", fieldName, "null") + addStatement("%N += value.%N.encodedSizeWithTag()", sizeName, fieldName) + } } else -> throw IllegalArgumentException("Unexpected element: $fieldOrOneOf") } @@ -1779,6 +1820,27 @@ class KotlinGenerator private constructor( addStatement("value.%L.encodeWithTag(writer)", fieldName) } } + for (sealedOneOf in message.sealedOneOfs()) { + val fieldName = nameAllocator[sealedOneOf] + val sealedClassName = (message.typeName as ClassName) + .nestedClass(nameAllocator[boxedOneOfClassName(sealedOneOf.name)]) + val subclassNameAllocator = sealedSubclassNameAllocator(sealedOneOf) + encodeCalls += buildCodeBlock { + beginControlFlow("when (val %N = value.%N)", fieldName, fieldName) + for (field in sealedOneOf.fields) { + val subclassType = sealedClassName.nestedClass(subclassNameAllocator[field]) + addStatement( + "is %T -> %L.encodeWithTag(writer, %L, %N.value)", + subclassType, + adapterFor(field), + field.tag, + fieldName, + ) + } + addStatement("null -> {}") + endControlFlow() + } + } encodeCalls += buildCodeBlock { addStatement("writer.writeBytes(value.unknownFields)") } @@ -1832,11 +1894,7 @@ class KotlinGenerator private constructor( } is OneOf -> { val fieldName = nameAllocator[fieldOrOneOf] - val oneOfClass = (message.typeName as ClassName) - .nestedClass(nameAllocator[boxedOneOfClassName(fieldOrOneOf.name)]) - .parameterizedBy(STAR) - val fieldClass = com.squareup.wire.OneOf::class.asClassName() - .parameterizedBy(oneOfClass, STAR).copy(nullable = true) + val fieldClass = message.oneOfClassFor(fieldOrOneOf, nameAllocator) val fieldDeclaration = CodeBlock.of("var %N: %T = %L", fieldName, fieldClass, "null") addStatement("%L", fieldDeclaration) } @@ -1914,7 +1972,8 @@ class KotlinGenerator private constructor( val decodeBlock = buildCodeBlock { val fields = message.fieldsAndFlatOneOfFieldsAndBoxedOneOfs().filterIsInstance() val boxOneOfs = message.boxOneOfs() - if (fields.isEmpty() && boxOneOfs.isEmpty()) { + val sealedOneOfs = message.sealedOneOfs() + if (fields.isEmpty() && boxOneOfs.isEmpty() && sealedOneOfs.isEmpty()) { addStatement( "val unknownFields = reader.%L(reader::readUnknownField)", protoReaderType.forEachTag, @@ -1958,6 +2017,40 @@ class KotlinGenerator private constructor( } } } + for (sealedOneOf in sealedOneOfs) { + val fieldName = nameAllocator[sealedOneOf] + val sealedClassName = (message.typeName as ClassName) + .nestedClass(nameAllocator[boxedOneOfClassName(sealedOneOf.name)]) + val subclassNameAllocator = sealedSubclassNameAllocator(sealedOneOf) + for (field in sealedOneOf.fields) { + val subclassType = sealedClassName.nestedClass(subclassNameAllocator[field]) + val adapterName = adapterFor(field) + if (field.type!!.isEnum) { + beginControlFlow("%L -> try", field.tag) + addStatement( + "${if (buildersOnly) "builder.%N" else "%N"} = %T(%L.decode(reader))", + fieldName, + subclassType, + adapterName, + ) + nextControlFlow("catch (e: %T)", ProtoAdapter.EnumConstantNotFoundException::class) + addStatement( + "reader.addUnknownField(%L, %T.VARINT, e.value.toLong())", + tag, + FieldEncoding::class, + ) + endControlFlow() + } else { + addStatement( + "%L -> ${if (buildersOnly) "builder.%N" else "%N"} = %T(%L.decode(reader))", + field.tag, + fieldName, + subclassType, + adapterName, + ) + } + } + } if (boxOneOfs.isEmpty()) { addStatement("else -> reader.readUnknownField(%L)", tag) } else { @@ -2899,6 +2992,76 @@ class KotlinGenerator private constructor( .build() } + /** + * Converts a snake_case field name to PascalCase for use as a sealed class subtype name. + * For example: `card_id` → `CardId`, `bank_account` → `BankAccount`. + */ + private fun String.toSealedSubclassName(): String = split("_").joinToString("") { part -> part.replaceFirstChar { it.uppercaseChar() } } + + /** + * Generates a sealed class for a oneof. + * + * Example: + * ``` + * public sealed class Method { + * public data class CardId(public val value: String) : Method() + * public data class BankAccount(public val value: BankAccount) : Method() + * public data class CashBalanceCents(public val value: Int) : Method() + * } + * ``` + */ + private fun sealedOneOfClass(sealedClassName: ClassName, oneOf: OneOf): TypeSpec { + val builder = TypeSpec.classBuilder(sealedClassName) + .addModifiers(KModifier.SEALED) + .apply { + if (oneOf.documentation.isNotBlank()) { + addKdoc("%L\n", oneOf.documentation.sanitizeKdoc()) + } + for (annotation in optionAnnotations(oneOf.options)) { + addAnnotation(annotation) + } + } + + val subclassNameAllocator = sealedSubclassNameAllocator(oneOf) + for (field in oneOf.fields) { + val subclassName = subclassNameAllocator[field] + val valueType = field.type!!.typeName + val subclass = TypeSpec.classBuilder(subclassName) + .addModifiers(DATA) + .superclass(sealedClassName) + .primaryConstructor( + FunSpec.constructorBuilder() + .apply { if (buildersOnly) addModifiers(INTERNAL) } + .addParameter("value", valueType) + .build(), + ) + .addProperty( + PropertySpec.builder("value", valueType) + .initializer("value") + .build(), + ) + .apply { + if (field.isDeprecated) { + addAnnotation( + AnnotationSpec.builder(Deprecated::class) + .addMember("message = %S", "${field.name} is deprecated") + .build(), + ) + } + for (annotation in optionAnnotations(field.options)) { + addAnnotation(annotation) + } + if (field.documentation.isNotBlank()) { + addKdoc("%L\n", field.documentation.sanitizeKdoc()) + } + } + .build() + builder.addType(subclass) + } + + return builder.build() + } + /** * Generates a class for this boxed oneof. * @@ -3059,36 +3222,51 @@ class KotlinGenerator private constructor( val fieldsAndFlatOneOfFields: List = declaredFields + extensionFields + flatOneOfs().flatMap { it.fields } - return (fieldsAndFlatOneOfFields + boxOneOfs()) + return (fieldsAndFlatOneOfFields + boxOneOfs() + sealedOneOfs()) .sortedBy { fieldOrOneOf -> when (fieldOrOneOf) { is Field -> fieldOrOneOf.location.line - // TODO(Benoit) If boxed oneofs without fields become a problem, we can add location to - // oneofs and use that. + // TODO(Benoit) If boxed/sealed oneofs without fields become a problem, we can add + // location to oneofs and use that. is OneOf -> fieldOrOneOf.fields.getOrNull(0)?.location?.line ?: 0 else -> throw IllegalArgumentException("Unexpected element: $fieldOrOneOf") } } } - private fun MessageType.flatOneOfs(): List { - val result = mutableListOf() - for (oneOf in this.oneOfs) { - if (oneOf.fields.size < boxOneOfsMinSize) { - result.add(oneOf) + private fun MessageType.flatOneOfs(): List = when (oneofMode) { + OneofMode.LEGACY -> oneOfs.filter { it.fields.size < boxOneOfsMinSize } + else -> emptyList() + } + + private fun MessageType.boxOneOfs(): List = when (oneofMode) { + OneofMode.LEGACY -> oneOfs.filter { it.fields.size >= boxOneOfsMinSize } + OneofMode.BOXED -> oneOfs + OneofMode.SEALED_CLASS -> emptyList() + } + + private fun MessageType.sealedOneOfs(): List = when (oneofMode) { + OneofMode.SEALED_CLASS -> oneOfs + else -> emptyList() + } + + private fun sealedSubclassNameAllocator(oneOf: OneOf): NameAllocator = sealedSubclassNameAllocatorStore.getOrPut(oneOf) { + NameAllocator(preallocateKeywords = !escapeKotlinKeywords).apply { + for (field in oneOf.fields) { + newName(field.name.toSealedSubclassName(), field) } } - return result } - private fun MessageType.boxOneOfs(): List = oneOfs.filter { it.fields.size >= boxOneOfsMinSize } - private fun MessageType.oneOfClassFor(oneOf: OneOf, nameAllocator: NameAllocator): TypeName { - val oneOfClass = (this.typeName as ClassName) + val nestedClass = (this.typeName as ClassName) .nestedClass(nameAllocator[boxedOneOfClassName(oneOf.name)]) - .parameterizedBy(STAR) - return com.squareup.wire.OneOf::class.asClassName() - .parameterizedBy(oneOfClass, STAR).copy(nullable = true) + return if (oneOf in sealedOneOfs()) { + nestedClass.copy(nullable = true) + } else { + com.squareup.wire.OneOf::class.asClassName() + .parameterizedBy(nestedClass.parameterizedBy(STAR), STAR).copy(nullable = true) + } } companion object { @@ -3170,6 +3348,7 @@ class KotlinGenerator private constructor( buildersOnly: Boolean = false, escapeKotlinKeywords: Boolean = false, enumMode: EnumMode = ENUM_CLASS, + oneofMode: OneofMode = OneofMode.LEGACY, emitProtoReader32: Boolean = false, mutableTypes: Boolean = false, explicitStreamingCalls: Boolean = false, @@ -3225,6 +3404,7 @@ class KotlinGenerator private constructor( buildersOnly = buildersOnly, escapeKotlinKeywords = escapeKotlinKeywords, enumMode = enumMode, + oneofMode = oneofMode, emitProtoReader32 = emitProtoReader32, mutableTypes = mutableTypes, explicitStreamingCalls = explicitStreamingCalls, @@ -3278,7 +3458,7 @@ class KotlinGenerator private constructor( private val Extend.annotationTargets: List get() = when (type) { - MESSAGE_OPTIONS, ENUM_OPTIONS, SERVICE_OPTIONS -> listOf(AnnotationTarget.CLASS) + MESSAGE_OPTIONS, ENUM_OPTIONS, SERVICE_OPTIONS, ONEOF_OPTIONS -> listOf(AnnotationTarget.CLASS) FIELD_OPTIONS, ENUM_VALUE_OPTIONS -> listOf(AnnotationTarget.PROPERTY, AnnotationTarget.FIELD) METHOD_OPTIONS -> listOf(AnnotationTarget.FUNCTION) else -> emptyList() diff --git a/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinSchemaHandler.kt b/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinSchemaHandler.kt index 8b25ffc943..a0892b2c98 100644 --- a/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinSchemaHandler.kt +++ b/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinSchemaHandler.kt @@ -81,6 +81,9 @@ class KotlinSchemaHandler( /** enum_class or sealed_class. See [EnumMode][com.squareup.wire.kotlin.EnumMode]. */ val enumMode: EnumMode = EnumMode.ENUM_CLASS, + /** legacy, boxed, or sealed_class. See [OneofMode][com.squareup.wire.kotlin.OneofMode]. */ + private val oneofMode: OneofMode = OneofMode.LEGACY, + /** * If true, adapters will generate decode functions for `ProtoReader32`. Use this optimization * when targeting Kotlin/JS, where `Long` cursors are inefficient. @@ -126,6 +129,7 @@ class KotlinSchemaHandler( buildersOnly = buildersOnly, escapeKotlinKeywords = escapeKotlinKeywords, enumMode = enumMode, + oneofMode = oneofMode, emitProtoReader32 = emitProtoReader32, mutableTypes = mutableTypes, explicitStreamingCalls = explicitStreamingCalls, diff --git a/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/OneofMode.kt b/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/OneofMode.kt new file mode 100644 index 0000000000..fa09a53012 --- /dev/null +++ b/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/OneofMode.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 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.wire.kotlin + +/** Defines how protobuf oneof types are generated. */ +enum class OneofMode { + /** + * Each oneof field is generated as a separate nullable property on the message class. If + * [KotlinTarget.boxOneOfsMinSize] is set, it'll be honored. + */ + LEGACY, + + /** + * Oneof fields are generated as boxed types. Effectively as is [KotlinTarget.boxOneOfsMinSize] + * was set to 1. + */ + BOXED, + + /** + * Eneof is generated as a nested sealed class with a data class subtype per field. The message + * holds a single nullable property of the sealed class type. + * + * Example for a oneof named `method` with fields `card_id`, `bank_account`, `cash_balance_cents`: + * ```kotlin + * sealed class Method { + * data class CardId(val value: String) : Method() + * data class BankAccount(val value: BankAccount) : Method() + * data class CashBalanceCents(val value: Int) : Method() + * } + * val method: Method? = null + * ``` + */ + SEALED_CLASS, +} diff --git a/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinGeneratorTest.kt b/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinGeneratorTest.kt index 34f4a9452d..9bcfccf5c4 100644 --- a/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinGeneratorTest.kt +++ b/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinGeneratorTest.kt @@ -2197,6 +2197,206 @@ class KotlinGeneratorTest { ) } + @Test fun sealedOneofGeneratesNestedSealedClass() { + val schema = buildSchema { + add( + "message.proto".toPath(), + """ + |syntax = "proto2"; + |message PaymentMethodChoice { + | oneof method { + | string card_id = 1; + | string cash = 2; + | } + |} + """.trimMargin(), + ) + } + val code = KotlinWithProfilesGenerator(schema) + .generateKotlin("PaymentMethodChoice", oneofMode = OneofMode.SEALED_CLASS) + assertThat(code).contains( + """ + |public class PaymentMethodChoice( + | public val method: Method? = null, + """.trimMargin(), + ) + assertThat(code).contains( + """ + | public sealed class Method { + | public data class CardId( + | public val `value`: String, + | ) : Method() + | + | public data class Cash( + | public val `value`: String, + | ) : Method() + | } + """.trimMargin(), + ) + } + + @Test fun sealedOneofAdapterEncodesAndDecodesCorrectly() { + val schema = buildSchema { + add( + "message.proto".toPath(), + """ + |syntax = "proto2"; + |message PaymentMethodChoice { + | oneof method { + | string card_id = 1; + | string cash = 2; + | } + |} + """.trimMargin(), + ) + } + val code = KotlinWithProfilesGenerator(schema) + .generateKotlin("PaymentMethodChoice", oneofMode = OneofMode.SEALED_CLASS) + // encode: when over the sealed class + assertThat(code).contains("when (val method = value.method)") + assertThat(code).contains("is Method.CardId ->") + assertThat(code).contains("is Method.Cash ->") + assertThat(code).contains("null -> {}") + // decode: inline tag cases + assertThat(code).contains("1 -> method = Method.CardId(") + assertThat(code).contains("2 -> method = Method.Cash(") + } + + @Test fun legacyOneofModeProducesIndividualNullableFields() { + val schema = buildSchema { + add( + "message.proto".toPath(), + """ + |syntax = "proto2"; + |message PaymentMethodChoice { + | oneof method { + | string card_id = 1; + | string cash = 2; + | } + |} + """.trimMargin(), + ) + } + val code = KotlinWithProfilesGenerator(schema) + .generateKotlin("PaymentMethodChoice", oneofMode = OneofMode.LEGACY) + assertThat(code).contains("public val card_id: String? = null") + assertThat(code).contains("public val cash: String? = null") + assertThat(code).doesNotContain("sealed class Method") + } + + @Test fun boxedOneofModeProducesOneOfProperty() { + val schema = buildSchema { + add( + "message.proto".toPath(), + """ + |syntax = "proto2"; + |message PaymentMethodChoice { + | oneof method { + | string card_id = 1; + | string cash = 2; + | } + |} + """.trimMargin(), + ) + } + val code = KotlinWithProfilesGenerator(schema) + .generateKotlin("PaymentMethodChoice", oneofMode = OneofMode.BOXED) + assertThat(code).contains("public val method: OneOf, *>? = null") + assertThat(code).doesNotContain("sealed class Method") + } + + @Test fun sealedOneofBuildersOnlyMakesSubclassConstructorsInternal() { + val schema = buildSchema { + add( + "message.proto".toPath(), + """ + |syntax = "proto2"; + |message PaymentMethodChoice { + | oneof method { + | string card_id = 1; + | string cash = 2; + | } + |} + """.trimMargin(), + ) + } + val code = KotlinWithProfilesGenerator(schema) + .generateKotlin("PaymentMethodChoice", oneofMode = OneofMode.SEALED_CLASS, buildersOnly = true) + assertThat(code).contains("internal constructor(") + assertThat(code).doesNotContain("public constructor(") + } + + @Test fun sealedOneofFieldOptionsAppliedAsAnnotationsOnSubtypes() { + val schema = buildSchema { + add( + "options.proto".toPath(), + """ + |syntax = "proto2"; + |package squareup.test; + |import "google/protobuf/descriptor.proto"; + |extend google.protobuf.FieldOptions { + | optional bool sensitive = 50001; + |} + """.trimMargin(), + ) + add( + "message.proto".toPath(), + """ + |syntax = "proto2"; + |import "options.proto"; + |message PaymentMethodChoice { + | oneof method { + | string card_id = 1 [(squareup.test.sensitive) = true]; + | string cash = 2; + | } + |} + """.trimMargin(), + ) + } + val code = KotlinWithProfilesGenerator(schema) + .generateKotlin("PaymentMethodChoice", oneofMode = OneofMode.SEALED_CLASS) + // @SensitiveOption(true) should appear on CardId but not on Cash + assertThat(code).contains("@SensitiveOption(true)") + assertThat(code).contains("public data class CardId(") + // Cash has no option applied + assertThat(code).contains("public data class Cash(") + } + + @Test fun sealedOneofOptionsAppliedAsAnnotationsOnSealedClass() { + val schema = buildSchema { + add( + "options.proto".toPath(), + """ + |syntax = "proto2"; + |package squareup.test; + |import "google/protobuf/descriptor.proto"; + |extend google.protobuf.OneofOptions { + | optional string category = 50003; + |} + """.trimMargin(), + ) + add( + "message.proto".toPath(), + """ + |syntax = "proto2"; + |import "options.proto"; + |message PaymentMethodChoice { + | oneof method { + | option (squareup.test.category) = "payment"; + | string card_id = 1; + | string cash = 2; + | } + |} + """.trimMargin(), + ) + } + val code = KotlinWithProfilesGenerator(schema) + .generateKotlin("PaymentMethodChoice", oneofMode = OneofMode.SEALED_CLASS) + // @CategoryOption("payment") should appear on the sealed class + assertThat(code).contains("@CategoryOption(\"payment\")") + assertThat(code).contains("public sealed class Method") + } + @Test fun hashCodeFunctionImplementation() { val schema = buildSchema { add( diff --git a/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinWithProfilesGenerator.kt b/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinWithProfilesGenerator.kt index 88ef0c6c59..8679513964 100644 --- a/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinWithProfilesGenerator.kt +++ b/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinWithProfilesGenerator.kt @@ -52,6 +52,7 @@ internal class KotlinWithProfilesGenerator(private val schema: Schema) { buildersOnly: Boolean = false, javaInterop: Boolean = false, enumMode: EnumMode = EnumMode.ENUM_CLASS, + oneofMode: OneofMode = OneofMode.LEGACY, mutableTypes: Boolean = false, makeImmutableCopies: Boolean = true, ): String { @@ -62,6 +63,7 @@ internal class KotlinWithProfilesGenerator(private val schema: Schema) { buildersOnly = buildersOnly, javaInterop = javaInterop, enumMode = enumMode, + oneofMode = oneofMode, mutableTypes = mutableTypes, makeImmutableCopies = makeImmutableCopies, ) From b95ff6952962d5fffb1d907cc3a40d16b947bb46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Quenaudon?= Date: Tue, 28 Apr 2026 14:18:25 +0100 Subject: [PATCH 2/5] Adds DSL in WireExtension and create golden files --- .../java/com/squareup/wire/schema/Target.kt | 4 +- wire-golden-files/build.gradle.kts | 7 + .../proto3/options/FieldOptionOption.kt | 25 +++ .../proto3/options/OneofOptionOption.kt | 21 ++ .../squareup/wire/boxedoneof/BoxedOneOfs.kt | 2 + .../wire/sealedoneof/SealedMessage.kt | 139 ++++++++++++++ .../squareup/wire/sealedoneof/SealedOneOfs.kt | 181 ++++++++++++++++++ .../proto/squareup/wire/boxed_oneof.proto | 6 +- .../squareup/wire/hundreds_redacted.proto | 4 - .../main/proto/squareup/wire/options.proto | 28 +++ .../proto/squareup/wire/sealed_oneof.proto | 35 ++++ wire-gradle-plugin/api/wire-gradle-plugin.api | 2 + .../com/squareup/wire/gradle/WireOutput.kt | 11 ++ .../api/wire-kotlin-generator.api | 3 +- .../squareup/wire/kotlin/KotlinGenerator.kt | 16 +- .../wire/kotlin/KotlinSchemaHandler.kt | 6 +- .../com/squareup/wire/kotlin/OneofMode.kt | 8 +- 17 files changed, 475 insertions(+), 23 deletions(-) create mode 100644 wire-golden-files/src/main/kotlin/squareup/proto3/options/FieldOptionOption.kt create mode 100644 wire-golden-files/src/main/kotlin/squareup/proto3/options/OneofOptionOption.kt create mode 100644 wire-golden-files/src/main/kotlin/squareup/wire/sealedoneof/SealedMessage.kt create mode 100644 wire-golden-files/src/main/kotlin/squareup/wire/sealedoneof/SealedOneOfs.kt create mode 100644 wire-golden-files/src/main/proto/squareup/wire/options.proto create mode 100644 wire-golden-files/src/main/proto/squareup/wire/sealed_oneof.proto diff --git a/wire-compiler/src/main/java/com/squareup/wire/schema/Target.kt b/wire-compiler/src/main/java/com/squareup/wire/schema/Target.kt index 3e5b6e86e1..c97729c0fe 100644 --- a/wire-compiler/src/main/java/com/squareup/wire/schema/Target.kt +++ b/wire-compiler/src/main/java/com/squareup/wire/schema/Target.kt @@ -134,8 +134,8 @@ data class KotlinTarget( /** enum_class or sealed_class. See [EnumMode][com.squareup.wire.kotlin.EnumMode]. */ val enumMode: EnumMode = EnumMode.ENUM_CLASS, - /** legacy, boxed, or sealed_class. See [OneofMode][com.squareup.wire.kotlin.OneofMode]. */ - val oneofMode: OneofMode = OneofMode.LEGACY, + /** flat, boxed, or sealed_class. See [OneofMode][com.squareup.wire.kotlin.OneofMode]. */ + val oneofMode: OneofMode = OneofMode.FLAT, /** * If true, adapters will generate decode functions for `ProtoReader32`. Use this optimization diff --git a/wire-golden-files/build.gradle.kts b/wire-golden-files/build.gradle.kts index df0e73467a..a0ca00ecb9 100644 --- a/wire-golden-files/build.gradle.kts +++ b/wire-golden-files/build.gradle.kts @@ -39,6 +39,13 @@ wire { boxOneOfsMinSize = 1 } + kotlin { + includes = listOf("squareup.wire.sealedoneof.*") + out = "src/main/kotlin" + oneofMode = "sealed_class" + buildersOnly = true + } + opaque("squareup.protos.opaque_types.OuterOpaqueType.InnerOpaqueType1") kotlin { includes = listOf("squareup.protos.opaque_types.*") diff --git a/wire-golden-files/src/main/kotlin/squareup/proto3/options/FieldOptionOption.kt b/wire-golden-files/src/main/kotlin/squareup/proto3/options/FieldOptionOption.kt new file mode 100644 index 0000000000..6eb2dc4efb --- /dev/null +++ b/wire-golden-files/src/main/kotlin/squareup/proto3/options/FieldOptionOption.kt @@ -0,0 +1,25 @@ +// Code generated by Wire protocol buffer compiler, do not edit. +// Source: squareup.proto3.options.field_option in squareup/wire/options.proto +@file:Suppress( + "DEPRECATION", + "RUNTIME_ANNOTATION_NOT_SUPPORTED", +) + +package squareup.proto3.options + +import kotlin.Int +import kotlin.Suppress +import kotlin.`annotation`.AnnotationRetention +import kotlin.`annotation`.AnnotationTarget +import kotlin.`annotation`.Retention +import kotlin.`annotation`.Target + +@Retention(AnnotationRetention.RUNTIME) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.PROPERTY, + AnnotationTarget.FIELD, +) +public annotation class FieldOptionOption( + public val `value`: Int, +) diff --git a/wire-golden-files/src/main/kotlin/squareup/proto3/options/OneofOptionOption.kt b/wire-golden-files/src/main/kotlin/squareup/proto3/options/OneofOptionOption.kt new file mode 100644 index 0000000000..cbfcb4e26a --- /dev/null +++ b/wire-golden-files/src/main/kotlin/squareup/proto3/options/OneofOptionOption.kt @@ -0,0 +1,21 @@ +// Code generated by Wire protocol buffer compiler, do not edit. +// Source: squareup.proto3.options.oneof_option in squareup/wire/options.proto +@file:Suppress( + "DEPRECATION", + "RUNTIME_ANNOTATION_NOT_SUPPORTED", +) + +package squareup.proto3.options + +import kotlin.Int +import kotlin.Suppress +import kotlin.`annotation`.AnnotationRetention +import kotlin.`annotation`.AnnotationTarget +import kotlin.`annotation`.Retention +import kotlin.`annotation`.Target + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.CLASS) +public annotation class OneofOptionOption( + public val `value`: Int, +) diff --git a/wire-golden-files/src/main/kotlin/squareup/wire/boxedoneof/BoxedOneOfs.kt b/wire-golden-files/src/main/kotlin/squareup/wire/boxedoneof/BoxedOneOfs.kt index 8abf2abed3..3133ec7d25 100644 --- a/wire-golden-files/src/main/kotlin/squareup/wire/boxedoneof/BoxedOneOfs.kt +++ b/wire-golden-files/src/main/kotlin/squareup/wire/boxedoneof/BoxedOneOfs.kt @@ -27,6 +27,7 @@ import kotlin.jvm.JvmField import kotlin.jvm.JvmStatic import kotlin.jvm.JvmSynthetic import okio.ByteString +import squareup.proto3.options.FieldOptionOption public class BoxedOneOfs( @JvmField @@ -149,6 +150,7 @@ public class BoxedOneOfs( public val VALUE_SECOND_VALUE: Value = Value(tag = 2, adapter = ProtoAdapter.STRING, declaredName = "second_value") + @FieldOptionOption(806) public val VALUE_VALUE: Value = Value(tag = 3, adapter = ProtoAdapter.STRING, declaredName = "value") diff --git a/wire-golden-files/src/main/kotlin/squareup/wire/sealedoneof/SealedMessage.kt b/wire-golden-files/src/main/kotlin/squareup/wire/sealedoneof/SealedMessage.kt new file mode 100644 index 0000000000..e1512c9b96 --- /dev/null +++ b/wire-golden-files/src/main/kotlin/squareup/wire/sealedoneof/SealedMessage.kt @@ -0,0 +1,139 @@ +// Code generated by Wire protocol buffer compiler, do not edit. +// Source: squareup.wire.sealedoneof.SealedMessage in squareup/wire/sealed_oneof.proto +@file:Suppress( + "DEPRECATION", + "RUNTIME_ANNOTATION_NOT_SUPPORTED", +) + +package squareup.wire.sealedoneof + +import com.squareup.wire.FieldEncoding +import com.squareup.wire.Message +import com.squareup.wire.ProtoAdapter +import com.squareup.wire.ProtoReader +import com.squareup.wire.ProtoWriter +import com.squareup.wire.ReverseProtoWriter +import com.squareup.wire.Syntax.PROTO_2 +import com.squareup.wire.WireField +import com.squareup.wire.`internal`.JvmField +import com.squareup.wire.`internal`.JvmSynthetic +import com.squareup.wire.`internal`.sanitize +import kotlin.Any +import kotlin.Boolean +import kotlin.Int +import kotlin.Long +import kotlin.String +import kotlin.Suppress +import kotlin.Unit +import okio.ByteString + +public class SealedMessage private constructor( + builder: Builder, + unknownFields: ByteString = ByteString.EMPTY, +) : Message(ADAPTER, unknownFields) { + @field:WireField( + tag = 1, + adapter = "com.squareup.wire.ProtoAdapter#STRING", + schemaIndex = 0, + ) + @JvmField + public val content: String? = builder.content + + override fun newBuilder(): Builder { + val builder = Builder() + builder.content = content + builder.addUnknownFields(unknownFields) + return builder + } + + override fun equals(other: Any?): Boolean { + if (other === this) return true + if (other !is SealedMessage) return false + if (unknownFields != other.unknownFields) return false + if (content != other.content) return false + return true + } + + override fun hashCode(): Int { + var result = super.hashCode + if (result == 0) { + result = unknownFields.hashCode() + result = result * 37 + (content?.hashCode() ?: 0) + super.hashCode = result + } + return result + } + + override fun toString(): String { + val result = mutableListOf() + if (content != null) result += """content=${sanitize(content)}""" + return result.joinToString(prefix = "SealedMessage{", separator = ", ", postfix = "}") + } + + public class Builder : Message.Builder() { + @JvmField + public var content: String? = null + + public fun content(content: String?): Builder { + this.content = content + return this + } + + override fun build(): SealedMessage = SealedMessage( + builder = this, + unknownFields = buildUnknownFields() + ) + } + + public companion object { + @JvmField + public val ADAPTER: ProtoAdapter = object : ProtoAdapter( + FieldEncoding.LENGTH_DELIMITED, + SealedMessage::class, + "type.googleapis.com/squareup.wire.sealedoneof.SealedMessage", + PROTO_2, + null, + "squareup/wire/sealed_oneof.proto" + ) { + override fun encodedSize(`value`: SealedMessage): Int { + var size = value.unknownFields.size + size += ProtoAdapter.STRING.encodedSizeWithTag(1, value.content) + return size + } + + override fun encode(writer: ProtoWriter, `value`: SealedMessage) { + ProtoAdapter.STRING.encodeWithTag(writer, 1, value.content) + writer.writeBytes(value.unknownFields) + } + + override fun encode(writer: ReverseProtoWriter, `value`: SealedMessage) { + writer.writeBytes(value.unknownFields) + ProtoAdapter.STRING.encodeWithTag(writer, 1, value.content) + } + + override fun decode(reader: ProtoReader): SealedMessage { + val builder = Builder() + val unknownFields = reader.forEachTag { tag -> + when (tag) { + 1 -> builder.content(ProtoAdapter.STRING.decode(reader)) + else -> reader.readUnknownField(tag) + } + } + return SealedMessage( + builder = builder, + unknownFields = unknownFields + ) + } + + override fun redact(`value`: SealedMessage): SealedMessage = SealedMessage( + builder = value.newBuilder(), + unknownFields = ByteString.EMPTY, + ) + } + + private const val serialVersionUID: Long = 0L + + @JvmSynthetic + public inline fun build(body: Builder.() -> Unit): SealedMessage = Builder().apply(body).build() + } +} diff --git a/wire-golden-files/src/main/kotlin/squareup/wire/sealedoneof/SealedOneOfs.kt b/wire-golden-files/src/main/kotlin/squareup/wire/sealedoneof/SealedOneOfs.kt new file mode 100644 index 0000000000..a9c6c2f0a5 --- /dev/null +++ b/wire-golden-files/src/main/kotlin/squareup/wire/sealedoneof/SealedOneOfs.kt @@ -0,0 +1,181 @@ +// Code generated by Wire protocol buffer compiler, do not edit. +// Source: squareup.wire.sealedoneof.SealedOneOfs in squareup/wire/sealed_oneof.proto +@file:Suppress( + "DEPRECATION", + "RUNTIME_ANNOTATION_NOT_SUPPORTED", +) + +package squareup.wire.sealedoneof + +import com.squareup.wire.FieldEncoding +import com.squareup.wire.Message +import com.squareup.wire.ProtoAdapter +import com.squareup.wire.ProtoReader +import com.squareup.wire.ProtoWriter +import com.squareup.wire.ReverseProtoWriter +import com.squareup.wire.Syntax.PROTO_2 +import com.squareup.wire.`internal`.JvmField +import com.squareup.wire.`internal`.JvmSynthetic +import kotlin.Any +import kotlin.Boolean +import kotlin.Deprecated +import kotlin.Int +import kotlin.Long +import kotlin.String +import kotlin.Suppress +import kotlin.Unit +import okio.ByteString +import squareup.proto3.options.FieldOptionOption +import squareup.proto3.options.OneofOptionOption + +public class SealedOneOfs private constructor( + builder: Builder, + unknownFields: ByteString = ByteString.EMPTY, +) : Message(ADAPTER, unknownFields) { + /** + * Where do you go? + */ + @JvmField + public val value_: Value? = builder.value_ + + override fun newBuilder(): Builder { + val builder = Builder() + builder.value_ = value_ + builder.addUnknownFields(unknownFields) + return builder + } + + override fun equals(other: Any?): Boolean { + if (other === this) return true + if (other !is SealedOneOfs) return false + if (unknownFields != other.unknownFields) return false + if (value_ != other.value_) return false + return true + } + + override fun hashCode(): Int { + var result = super.hashCode + if (result == 0) { + result = unknownFields.hashCode() + result = result * 37 + (value_?.hashCode() ?: 0) + super.hashCode = result + } + return result + } + + override fun toString(): String { + val result = mutableListOf() + if (value_ != null) result += """value_=$value_""" + return result.joinToString(prefix = "SealedOneOfs{", separator = ", ", postfix = "}") + } + + public class Builder : Message.Builder() { + @JvmField + public var value_: Value? = null + + /** + * Where do you go? + */ + public fun value_(value_: Value?): Builder { + this.value_ = value_ + return this + } + + override fun build(): SealedOneOfs = SealedOneOfs( + builder = this, + unknownFields = buildUnknownFields() + ) + } + + /** + * Where do you go? + */ + @OneofOptionOption(33) + public sealed class Value { + public data class FirstValue( + public val `value`: SealedMessage, + ) : SealedOneOfs.Value() + + @Deprecated(message = "second_value is deprecated") + public data class SecondValue( + public val `value`: String, + ) : SealedOneOfs.Value() + + /** + * This one is a good candidate. + */ + @FieldOptionOption(806) + public data class Value( + public val `value`: Int, + ) : SealedOneOfs.Value() + } + + public companion object { + @JvmField + public val ADAPTER: ProtoAdapter = object : ProtoAdapter( + FieldEncoding.LENGTH_DELIMITED, + SealedOneOfs::class, + "type.googleapis.com/squareup.wire.sealedoneof.SealedOneOfs", + PROTO_2, + null, + "squareup/wire/sealed_oneof.proto" + ) { + override fun encodedSize(`value`: SealedOneOfs): Int { + var size = value.unknownFields.size + when (val value_ = value.value_) { + is Value.FirstValue -> size += SealedMessage.ADAPTER.encodedSizeWithTag(1, value_.value) + is Value.SecondValue -> size += ProtoAdapter.STRING.encodedSizeWithTag(2, value_.value) + is Value.Value -> size += ProtoAdapter.INT32.encodedSizeWithTag(3, value_.value) + null -> {} + } + return size + } + + override fun encode(writer: ProtoWriter, `value`: SealedOneOfs) { + when (val value_ = value.value_) { + is Value.FirstValue -> SealedMessage.ADAPTER.encodeWithTag(writer, 1, value_.value) + is Value.SecondValue -> ProtoAdapter.STRING.encodeWithTag(writer, 2, value_.value) + is Value.Value -> ProtoAdapter.INT32.encodeWithTag(writer, 3, value_.value) + null -> {} + } + writer.writeBytes(value.unknownFields) + } + + override fun encode(writer: ReverseProtoWriter, `value`: SealedOneOfs) { + writer.writeBytes(value.unknownFields) + when (val value_ = value.value_) { + is Value.FirstValue -> SealedMessage.ADAPTER.encodeWithTag(writer, 1, value_.value) + is Value.SecondValue -> ProtoAdapter.STRING.encodeWithTag(writer, 2, value_.value) + is Value.Value -> ProtoAdapter.INT32.encodeWithTag(writer, 3, value_.value) + null -> {} + } + } + + override fun decode(reader: ProtoReader): SealedOneOfs { + val builder = Builder() + val unknownFields = reader.forEachTag { tag -> + when (tag) { + 1 -> builder.value_ = Value.FirstValue(SealedMessage.ADAPTER.decode(reader)) + 2 -> builder.value_ = Value.SecondValue(ProtoAdapter.STRING.decode(reader)) + 3 -> builder.value_ = Value.Value(ProtoAdapter.INT32.decode(reader)) + else -> reader.readUnknownField(tag) + } + } + return SealedOneOfs( + builder = builder, + unknownFields = unknownFields + ) + } + + override fun redact(`value`: SealedOneOfs): SealedOneOfs = SealedOneOfs( + builder = value.newBuilder(), + unknownFields = ByteString.EMPTY, + ) + } + + private const val serialVersionUID: Long = 0L + + @JvmSynthetic + public inline fun build(body: Builder.() -> Unit): SealedOneOfs = Builder().apply(body).build() + } +} diff --git a/wire-golden-files/src/main/proto/squareup/wire/boxed_oneof.proto b/wire-golden-files/src/main/proto/squareup/wire/boxed_oneof.proto index affc6423f6..e1817498b2 100644 --- a/wire-golden-files/src/main/proto/squareup/wire/boxed_oneof.proto +++ b/wire-golden-files/src/main/proto/squareup/wire/boxed_oneof.proto @@ -16,10 +16,12 @@ syntax = "proto2"; package squareup.wire.boxedoneof; +import "squareup/wire/options.proto"; + message BoxedOneOfs { oneof value { string first_value = 1; string second_value = 2; - string value = 3; + string value = 3 [(squareup.proto3.options.field_option) = 806]; } -} \ No newline at end of file +} diff --git a/wire-golden-files/src/main/proto/squareup/wire/hundreds_redacted.proto b/wire-golden-files/src/main/proto/squareup/wire/hundreds_redacted.proto index 8bb4caa67c..c06383128b 100644 --- a/wire-golden-files/src/main/proto/squareup/wire/hundreds_redacted.proto +++ b/wire-golden-files/src/main/proto/squareup/wire/hundreds_redacted.proto @@ -206,7 +206,3 @@ message HundredsFields { } message Field {} - -// extend google.protobuf.FieldOptions { -// optional bool redacted = 22222; -// } diff --git a/wire-golden-files/src/main/proto/squareup/wire/options.proto b/wire-golden-files/src/main/proto/squareup/wire/options.proto new file mode 100644 index 0000000000..05e4a18801 --- /dev/null +++ b/wire-golden-files/src/main/proto/squareup/wire/options.proto @@ -0,0 +1,28 @@ +/* + * Copyright 2026 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 + * + * http://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. + */ +syntax = "proto3"; + +package squareup.proto3.options; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.OneofOptions { + int32 oneof_option = 59900; +} + +extend google.protobuf.FieldOptions { + int32 field_option = 59901; +} diff --git a/wire-golden-files/src/main/proto/squareup/wire/sealed_oneof.proto b/wire-golden-files/src/main/proto/squareup/wire/sealed_oneof.proto new file mode 100644 index 0000000000..c1fb17fd41 --- /dev/null +++ b/wire-golden-files/src/main/proto/squareup/wire/sealed_oneof.proto @@ -0,0 +1,35 @@ +/* + * Copyright 2023 Block 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 + * + * http://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. + */ +syntax = "proto2"; +package squareup.wire.sealedoneof; + +import "squareup/wire/options.proto"; + +message SealedOneOfs { + // Where do you go? + oneof value { + option (squareup.proto3.options.oneof_option) = 33; + + SealedMessage first_value = 1; + string second_value = 2 [deprecated = true]; + // This one is a good candidate. + int32 value = 3 [(squareup.proto3.options.field_option) = 806]; + } +} + +message SealedMessage { + optional string content = 1; +} diff --git a/wire-gradle-plugin/api/wire-gradle-plugin.api b/wire-gradle-plugin/api/wire-gradle-plugin.api index c947120db2..01a0b01c49 100644 --- a/wire-gradle-plugin/api/wire-gradle-plugin.api +++ b/wire-gradle-plugin/api/wire-gradle-plugin.api @@ -59,6 +59,7 @@ public class com/squareup/wire/gradle/KotlinOutput : com/squareup/wire/gradle/Wi public final fun getMakeImmutableCopies ()Z public final fun getMutableTypes ()Z public final fun getNameSuffix ()Ljava/lang/String; + public final fun getOneofMode ()Ljava/lang/String; public final fun getRpcCallStyle ()Ljava/lang/String; public final fun getRpcRole ()Ljava/lang/String; public final fun getSingleMethodServices ()Z @@ -79,6 +80,7 @@ public class com/squareup/wire/gradle/KotlinOutput : com/squareup/wire/gradle/Wi public final fun setMakeImmutableCopies (Z)V public final fun setMutableTypes (Z)V public final fun setNameSuffix (Ljava/lang/String;)V + public final fun setOneofMode (Ljava/lang/String;)V public final fun setRpcCallStyle (Ljava/lang/String;)V public final fun setRpcRole (Ljava/lang/String;)V public final fun setSingleMethodServices (Z)V diff --git a/wire-gradle-plugin/src/main/kotlin/com/squareup/wire/gradle/WireOutput.kt b/wire-gradle-plugin/src/main/kotlin/com/squareup/wire/gradle/WireOutput.kt index 216f70efa3..cf499c1bd7 100644 --- a/wire-gradle-plugin/src/main/kotlin/com/squareup/wire/gradle/WireOutput.kt +++ b/wire-gradle-plugin/src/main/kotlin/com/squareup/wire/gradle/WireOutput.kt @@ -16,6 +16,7 @@ package com.squareup.wire.gradle import com.squareup.wire.kotlin.EnumMode +import com.squareup.wire.kotlin.OneofMode import com.squareup.wire.kotlin.RpcCallStyle import com.squareup.wire.kotlin.RpcRole import com.squareup.wire.schema.CustomTarget @@ -146,6 +147,9 @@ open class KotlinOutput @Inject constructor() : WireOutput() { /** enum_class or sealed_class. See [EnumMode][com.squareup.wire.kotlin.EnumMode]. */ var enumMode: String = "enum_class" + /** flat, boxed, or sealed_class. See [OneofMode][com.squareup.wire.kotlin.OneofMode]. */ + var oneofMode: String = "flat" + /** * If true, adapters will generate decode functions for `ProtoReader32`. Use this optimization * when targeting Kotlin/JS, where `Long` cursors are inefficient. @@ -198,6 +202,12 @@ open class KotlinOutput @Inject constructor() : WireOutput() { "Unknown enumMode $enumMode. Valid values: ${EnumMode.values().contentToString()}", ) + val oneofMode = OneofMode.values() + .singleOrNull { it.toString().equals(oneofMode, ignoreCase = true) } + ?: throw IllegalArgumentException( + "Unknown oneofMode $oneofMode. Valid values: ${OneofMode.values().contentToString()}", + ) + return KotlinTarget( includes = includes ?: listOf("*"), excludes = excludes ?: listOf(), @@ -215,6 +225,7 @@ open class KotlinOutput @Inject constructor() : WireOutput() { buildersOnly = buildersOnly, escapeKotlinKeywords = escapeKotlinKeywords, enumMode = enumMode, + oneofMode = oneofMode, emitProtoReader32 = emitProtoReader32, mutableTypes = mutableTypes, explicitStreamingCalls = explicitStreamingCalls, diff --git a/wire-kotlin-generator/api/wire-kotlin-generator.api b/wire-kotlin-generator/api/wire-kotlin-generator.api index 84d3502476..8a4e86b6a7 100644 --- a/wire-kotlin-generator/api/wire-kotlin-generator.api +++ b/wire-kotlin-generator/api/wire-kotlin-generator.api @@ -31,7 +31,6 @@ public final class com/squareup/wire/kotlin/KotlinSchemaHandler : com/squareup/w public static final field Companion Lcom/squareup/wire/kotlin/KotlinSchemaHandler$Companion; public fun (Ljava/lang/String;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ZILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;Lcom/squareup/wire/kotlin/OneofMode;ZZZZ)V public synthetic fun (Ljava/lang/String;ZZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ZILjava/lang/String;ZZLcom/squareup/wire/kotlin/EnumMode;Lcom/squareup/wire/kotlin/OneofMode;ZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getEnumMode ()Lcom/squareup/wire/kotlin/EnumMode; public fun handle (Lcom/squareup/wire/schema/Extend;Lcom/squareup/wire/schema/Field;Lcom/squareup/wire/schema/SchemaHandler$Context;)Lokio/Path; public fun handle (Lcom/squareup/wire/schema/Schema;Lcom/squareup/wire/schema/SchemaHandler$Context;)V public fun handle (Lcom/squareup/wire/schema/Service;Lcom/squareup/wire/schema/SchemaHandler$Context;)Ljava/util/List; @@ -43,7 +42,7 @@ public final class com/squareup/wire/kotlin/KotlinSchemaHandler$Companion { public final class com/squareup/wire/kotlin/OneofMode : java/lang/Enum { public static final field BOXED Lcom/squareup/wire/kotlin/OneofMode; - public static final field LEGACY Lcom/squareup/wire/kotlin/OneofMode; + public static final field FLAT Lcom/squareup/wire/kotlin/OneofMode; public static final field SEALED_CLASS Lcom/squareup/wire/kotlin/OneofMode; public static fun getEntries ()Lkotlin/enums/EnumEntries; public static fun valueOf (Ljava/lang/String;)Lcom/squareup/wire/kotlin/OneofMode; diff --git a/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinGenerator.kt b/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinGenerator.kt index 74f8d777dc..9403b9b890 100644 --- a/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinGenerator.kt +++ b/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinGenerator.kt @@ -84,7 +84,6 @@ import com.squareup.wire.internal.boxedOneOfKeyFieldName import com.squareup.wire.internal.boxedOneOfKeysFieldName import com.squareup.wire.kotlin.EnumMode.ENUM_CLASS import com.squareup.wire.kotlin.EnumMode.SEALED_CLASS -import com.squareup.wire.kotlin.OneofMode import com.squareup.wire.schema.EnclosingType import com.squareup.wire.schema.EnumConstant import com.squareup.wire.schema.EnumType @@ -3031,7 +3030,6 @@ class KotlinGenerator private constructor( .superclass(sealedClassName) .primaryConstructor( FunSpec.constructorBuilder() - .apply { if (buildersOnly) addModifiers(INTERNAL) } .addParameter("value", valueType) .build(), ) @@ -3235,12 +3233,12 @@ class KotlinGenerator private constructor( } private fun MessageType.flatOneOfs(): List = when (oneofMode) { - OneofMode.LEGACY -> oneOfs.filter { it.fields.size < boxOneOfsMinSize } + OneofMode.FLAT -> oneOfs.filter { it.fields.size < boxOneOfsMinSize } else -> emptyList() } private fun MessageType.boxOneOfs(): List = when (oneofMode) { - OneofMode.LEGACY -> oneOfs.filter { it.fields.size >= boxOneOfsMinSize } + OneofMode.FLAT -> oneOfs.filter { it.fields.size >= boxOneOfsMinSize } OneofMode.BOXED -> oneOfs OneofMode.SEALED_CLASS -> emptyList() } @@ -3348,7 +3346,7 @@ class KotlinGenerator private constructor( buildersOnly: Boolean = false, escapeKotlinKeywords: Boolean = false, enumMode: EnumMode = ENUM_CLASS, - oneofMode: OneofMode = OneofMode.LEGACY, + oneofMode: OneofMode = OneofMode.FLAT, emitProtoReader32: Boolean = false, mutableTypes: Boolean = false, explicitStreamingCalls: Boolean = false, @@ -3459,7 +3457,13 @@ class KotlinGenerator private constructor( private val Extend.annotationTargets: List get() = when (type) { MESSAGE_OPTIONS, ENUM_OPTIONS, SERVICE_OPTIONS, ONEOF_OPTIONS -> listOf(AnnotationTarget.CLASS) - FIELD_OPTIONS, ENUM_VALUE_OPTIONS -> listOf(AnnotationTarget.PROPERTY, AnnotationTarget.FIELD) + FIELD_OPTIONS, ENUM_VALUE_OPTIONS -> + listOf( + // Class is required when oneofMode is `SEALED_CLASS`. + AnnotationTarget.CLASS, + AnnotationTarget.PROPERTY, + AnnotationTarget.FIELD, + ) METHOD_OPTIONS -> listOf(AnnotationTarget.FUNCTION) else -> emptyList() } diff --git a/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinSchemaHandler.kt b/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinSchemaHandler.kt index a0892b2c98..c4fb7c40df 100644 --- a/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinSchemaHandler.kt +++ b/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinSchemaHandler.kt @@ -79,10 +79,10 @@ class KotlinSchemaHandler( private val escapeKotlinKeywords: Boolean = false, /** enum_class or sealed_class. See [EnumMode][com.squareup.wire.kotlin.EnumMode]. */ - val enumMode: EnumMode = EnumMode.ENUM_CLASS, + private val enumMode: EnumMode = EnumMode.ENUM_CLASS, - /** legacy, boxed, or sealed_class. See [OneofMode][com.squareup.wire.kotlin.OneofMode]. */ - private val oneofMode: OneofMode = OneofMode.LEGACY, + /** flat, boxed, or sealed_class. See [OneofMode][com.squareup.wire.kotlin.OneofMode]. */ + private val oneofMode: OneofMode = OneofMode.FLAT, /** * If true, adapters will generate decode functions for `ProtoReader32`. Use this optimization diff --git a/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/OneofMode.kt b/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/OneofMode.kt index fa09a53012..f2e4d66e31 100644 --- a/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/OneofMode.kt +++ b/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/OneofMode.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 Square, Inc. + * Copyright (C) 2026 Square, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,16 +21,16 @@ enum class OneofMode { * Each oneof field is generated as a separate nullable property on the message class. If * [KotlinTarget.boxOneOfsMinSize] is set, it'll be honored. */ - LEGACY, + FLAT, /** - * Oneof fields are generated as boxed types. Effectively as is [KotlinTarget.boxOneOfsMinSize] + * Oneof fields are generated as boxed types. Effectively as if [KotlinTarget.boxOneOfsMinSize] * was set to 1. */ BOXED, /** - * Eneof is generated as a nested sealed class with a data class subtype per field. The message + * Oneof is generated as a nested sealed class with a data class subtype per field. The message * holds a single nullable property of the sealed class type. * * Example for a oneof named `method` with fields `card_id`, `bank_account`, `cash_balance_cents`: From 3f0bceae85bcfbdf062b3356e79414d3bbedab96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Quenaudon?= Date: Tue, 28 Apr 2026 15:13:26 +0100 Subject: [PATCH 3/5] OneofMode entry in WireCompiler --- wire-compiler/api/wire-compiler.api | 3 ++- .../java/com/squareup/wire/WireCompiler.kt | 15 ++++++++++++ .../com/squareup/wire/WireCompilerTest.kt | 5 ++++ .../wire/kotlin/KotlinGeneratorTest.kt | 23 +------------------ .../kotlin/KotlinWithProfilesGenerator.kt | 3 +-- 5 files changed, 24 insertions(+), 25 deletions(-) diff --git a/wire-compiler/api/wire-compiler.api b/wire-compiler/api/wire-compiler.api index 2f0aaa91af..9f6d9ea85d 100644 --- a/wire-compiler/api/wire-compiler.api +++ b/wire-compiler/api/wire-compiler.api @@ -8,7 +8,7 @@ public final class com/squareup/wire/DryRunFileSystem : okio/ForwardingFileSyste public final class com/squareup/wire/WireCompiler { public static final field CODE_GENERATED_BY_WIRE Ljava/lang/String; public static final field Companion Lcom/squareup/wire/WireCompiler$Companion; - public synthetic fun (Lokio/FileSystem;Lcom/squareup/wire/WireLogger;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;ZLjava/util/Map;ZZZZZZZZIZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ZLjava/lang/String;ZZZZLcom/squareup/wire/kotlin/EnumMode;Ljava/util/List;Ljava/util/Map;Ljava/util/List;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lokio/FileSystem;Lcom/squareup/wire/WireLogger;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;ZLjava/util/Map;ZZZZZZZZIZZZLcom/squareup/wire/kotlin/RpcCallStyle;Lcom/squareup/wire/kotlin/RpcRole;ZLjava/lang/String;ZZZZLcom/squareup/wire/kotlin/EnumMode;Lcom/squareup/wire/kotlin/OneofMode;Ljava/util/List;Ljava/util/Map;Ljava/util/List;IILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun compile ()V public final fun createRun ()Lcom/squareup/wire/schema/WireRun; public static final fun forArgs (Ljava/nio/file/FileSystem;Lcom/squareup/wire/WireLogger;[Ljava/lang/String;)Lcom/squareup/wire/WireCompiler; @@ -35,6 +35,7 @@ public final class com/squareup/wire/WireCompiler { public final fun getKotlinExclusive ()Z public final fun getKotlinExplicitStreamingCalls ()Z public final fun getKotlinNameSuffix ()Ljava/lang/String; + public final fun getKotlinOneofMode ()Lcom/squareup/wire/kotlin/OneofMode; public final fun getKotlinOut ()Ljava/lang/String; public final fun getKotlinRpcCallStyle ()Lcom/squareup/wire/kotlin/RpcCallStyle; public final fun getKotlinRpcRole ()Lcom/squareup/wire/kotlin/RpcRole; diff --git a/wire-compiler/src/main/java/com/squareup/wire/WireCompiler.kt b/wire-compiler/src/main/java/com/squareup/wire/WireCompiler.kt index 0753f7f6aa..2de83e6fb8 100644 --- a/wire-compiler/src/main/java/com/squareup/wire/WireCompiler.kt +++ b/wire-compiler/src/main/java/com/squareup/wire/WireCompiler.kt @@ -16,6 +16,7 @@ package com.squareup.wire import com.squareup.wire.kotlin.EnumMode +import com.squareup.wire.kotlin.OneofMode import com.squareup.wire.kotlin.RpcCallStyle import com.squareup.wire.kotlin.RpcRole import com.squareup.wire.schema.CustomTarget @@ -50,6 +51,7 @@ import okio.openZip * [--java_out=] * [--kotlin_out=] * [--kotlin_enum_mode=] + * [--kotlin_oneof_mode=] * [--swift_out=] * [--custom_out=] * [--schema_handler_factory_class=] @@ -71,6 +73,10 @@ import okio.openZip * (default) to generate Kotlin enum classes, or `sealed_class` to generate sealed classes with * data objects for each value and an Unrecognized case. * + * `--kotlin_oneof_mode` controls how Kotlin oneofs are generated. Valid values are `flat` + * (default) to generate each oneof field as a separate nullable property, `boxed` to generate + * boxed oneofs, or `sealed_class` to generate a nested sealed class with a data class per field. + * * `--swift_out` should provide the folder where the files generated by the Swift code generator * should be placed. * @@ -151,6 +157,7 @@ class WireCompiler internal constructor( val emitProtoReader32: Boolean, val kotlinExplicitStreamingCalls: Boolean, val kotlinEnumMode: EnumMode, + val kotlinOneofMode: OneofMode, val eventListenerFactoryClasses: List, val customOptions: Map, val opaqueTypes: List = listOf(), @@ -194,6 +201,7 @@ class WireCompiler internal constructor( emitProtoReader32 = emitProtoReader32, explicitStreamingCalls = kotlinExplicitStreamingCalls, enumMode = kotlinEnumMode, + oneofMode = kotlinOneofMode, ) } if (swiftOut != null) { @@ -305,6 +313,7 @@ class WireCompiler internal constructor( private const val KOTLIN_ESCAPE_KEYWORDS = "--kotlin_escape_keywords" private const val EMIT_PROTO_READER_32 = "--emit_proto_reader_32" private const val KOTLIN_ENUM_MODE = "--kotlin_enum_mode=" + private const val KOTLIN_ONEOF_MODE = "--kotlin_oneof_mode=" private const val CUSTOM_OPTION_FLAG = "--custom_option=" private const val OPAQUE_TYPES_FLAG = "--opaque_types=" private const val IGNORE_UNUSED_ROOTS_AND_PRUNES = "--ignore_unused_roots_and_prunes" @@ -371,6 +380,7 @@ class WireCompiler internal constructor( var kotlinEscapeKeywords = false var emitProtoReader32 = false var kotlinEnumMode = EnumMode.ENUM_CLASS + var kotlinOneofMode = OneofMode.FLAT var kotlinExplicitStreamingCalls = false var dryRun = false val customOptions = mutableMapOf() @@ -413,6 +423,10 @@ class WireCompiler internal constructor( kotlinEnumMode = EnumMode.valueOf(arg.substring(KOTLIN_ENUM_MODE.length).uppercase()) } + arg.startsWith(KOTLIN_ONEOF_MODE) -> { + kotlinOneofMode = OneofMode.valueOf(arg.substring(KOTLIN_ONEOF_MODE.length).uppercase()) + } + arg.startsWith(SWIFT_OUT_FLAG) -> { swiftOut = arg.substring(SWIFT_OUT_FLAG.length) } @@ -551,6 +565,7 @@ class WireCompiler internal constructor( kotlinEscapeKeywords = kotlinEscapeKeywords, emitProtoReader32 = emitProtoReader32, kotlinEnumMode = kotlinEnumMode, + kotlinOneofMode = kotlinOneofMode, eventListenerFactoryClasses = eventListenerFactoryClasses, customOptions = customOptions, opaqueTypes = opaqueTypes, diff --git a/wire-compiler/src/test/java/com/squareup/wire/WireCompilerTest.kt b/wire-compiler/src/test/java/com/squareup/wire/WireCompilerTest.kt index 2fad6d3a54..ba7381b99e 100644 --- a/wire-compiler/src/test/java/com/squareup/wire/WireCompilerTest.kt +++ b/wire-compiler/src/test/java/com/squareup/wire/WireCompilerTest.kt @@ -29,6 +29,7 @@ import assertk.assertions.isNull import assertk.assertions.isTrue import assertk.assertions.prop import com.squareup.wire.kotlin.EnumMode +import com.squareup.wire.kotlin.OneofMode import com.squareup.wire.kotlin.RpcCallStyle import com.squareup.wire.kotlin.RpcRole import com.squareup.wire.schema.CustomTarget @@ -158,6 +159,7 @@ class WireCompilerTest { "--kotlin_escape_keywords", "--emit_proto_reader_32", "--kotlin_enum_mode=sealed_class", + "--kotlin_oneof_mode=sealed_class", // "--custom_option=a,1", // "--custom_option=b,2", "--opaque_types=opaque_types", @@ -205,6 +207,7 @@ class WireCompilerTest { buildersOnly = true, escapeKotlinKeywords = true, enumMode = EnumMode.SEALED_CLASS, + oneofMode = OneofMode.SEALED_CLASS, emitProtoReader32 = true, mutableTypes = false, explicitStreamingCalls = true, @@ -450,6 +453,7 @@ class WireCompilerTest { "--kotlin_escape_keywords", "--emit_proto_reader_32", "--kotlin_enum_mode=sealed_class", + "--kotlin_oneof_mode=sealed_class", "--custom_option=a,1", "--custom_option=b,2", "--kotlin_explicit_streaming_calls", @@ -488,6 +492,7 @@ class WireCompilerTest { buildersOnly = true, escapeKotlinKeywords = true, enumMode = EnumMode.SEALED_CLASS, + oneofMode = OneofMode.SEALED_CLASS, emitProtoReader32 = true, mutableTypes = false, explicitStreamingCalls = true, diff --git a/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinGeneratorTest.kt b/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinGeneratorTest.kt index 9bcfccf5c4..462c277d4f 100644 --- a/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinGeneratorTest.kt +++ b/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinGeneratorTest.kt @@ -2278,7 +2278,7 @@ class KotlinGeneratorTest { ) } val code = KotlinWithProfilesGenerator(schema) - .generateKotlin("PaymentMethodChoice", oneofMode = OneofMode.LEGACY) + .generateKotlin("PaymentMethodChoice", oneofMode = OneofMode.FLAT) assertThat(code).contains("public val card_id: String? = null") assertThat(code).contains("public val cash: String? = null") assertThat(code).doesNotContain("sealed class Method") @@ -2305,27 +2305,6 @@ class KotlinGeneratorTest { assertThat(code).doesNotContain("sealed class Method") } - @Test fun sealedOneofBuildersOnlyMakesSubclassConstructorsInternal() { - val schema = buildSchema { - add( - "message.proto".toPath(), - """ - |syntax = "proto2"; - |message PaymentMethodChoice { - | oneof method { - | string card_id = 1; - | string cash = 2; - | } - |} - """.trimMargin(), - ) - } - val code = KotlinWithProfilesGenerator(schema) - .generateKotlin("PaymentMethodChoice", oneofMode = OneofMode.SEALED_CLASS, buildersOnly = true) - assertThat(code).contains("internal constructor(") - assertThat(code).doesNotContain("public constructor(") - } - @Test fun sealedOneofFieldOptionsAppliedAsAnnotationsOnSubtypes() { val schema = buildSchema { add( diff --git a/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinWithProfilesGenerator.kt b/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinWithProfilesGenerator.kt index 8679513964..6f67d9db71 100644 --- a/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinWithProfilesGenerator.kt +++ b/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinWithProfilesGenerator.kt @@ -16,7 +16,6 @@ package com.squareup.wire.kotlin import com.squareup.kotlinpoet.FileSpec -import com.squareup.wire.kotlin.EnumMode.ENUM_CLASS import com.squareup.wire.schema.Location import com.squareup.wire.schema.Profile import com.squareup.wire.schema.Schema @@ -52,7 +51,7 @@ internal class KotlinWithProfilesGenerator(private val schema: Schema) { buildersOnly: Boolean = false, javaInterop: Boolean = false, enumMode: EnumMode = EnumMode.ENUM_CLASS, - oneofMode: OneofMode = OneofMode.LEGACY, + oneofMode: OneofMode = OneofMode.FLAT, mutableTypes: Boolean = false, makeImmutableCopies: Boolean = true, ): String { From 8e123e038de1b6edb55a4e4b9d2157ffa7486470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Quenaudon?= Date: Tue, 28 Apr 2026 16:26:16 +0100 Subject: [PATCH 4/5] Interop tests --- .../java/com/squareup/wire/WireCompiler.kt | 2 +- .../squareup/wire/sealedoneof/SealedOneOfs.kt | 16 +++ .../squareup/wire/kotlin/KotlinGenerator.kt | 43 +++++-- .../wire/kotlin/KotlinGeneratorTest.kt | 17 ++- .../build.gradle.kts | 19 +++ .../kotlin/interop/interop_sealed_test.proto | 37 ++++++ .../interop_sealed_test_buildersonly.proto | 37 ++++++ .../proto2/kotlin/interop/interop_test.proto | 16 +++ .../kotlin/interop/interop_sealed_test.proto | 37 ++++++ .../interop_sealed_test_buildersonly.proto | 37 ++++++ .../java/com/squareup/wire/InteropChecker.kt | 55 ++++++++- .../java/com/squareup/wire/InteropTest.kt | 56 +++++++++ wire-runtime/api/wire-runtime.api | 12 ++ .../com/squareup/wire/WireOneofField.kt | 49 ++++++++ .../com/squareup/wire/WireSealedOneof.kt | 32 +++++ .../squareup/wire/KotlinConstructorBuilder.kt | 52 ++++---- .../wire/internal/SealedOneOfBinding.kt | 112 ++++++++++++++++++ .../com/squareup/wire/internal/reflection.kt | 13 ++ 18 files changed, 598 insertions(+), 44 deletions(-) create mode 100644 wire-protoc-compatibility-tests/src/main/proto/squareup/proto2/kotlin/interop/interop_sealed_test.proto create mode 100644 wire-protoc-compatibility-tests/src/main/proto/squareup/proto2/kotlin/interop/interop_sealed_test_buildersonly.proto create mode 100644 wire-protoc-compatibility-tests/src/main/proto/squareup/proto3/kotlin/interop/interop_sealed_test.proto create mode 100644 wire-protoc-compatibility-tests/src/main/proto/squareup/proto3/kotlin/interop/interop_sealed_test_buildersonly.proto create mode 100644 wire-runtime/src/commonMain/kotlin/com/squareup/wire/WireOneofField.kt create mode 100644 wire-runtime/src/commonMain/kotlin/com/squareup/wire/WireSealedOneof.kt create mode 100644 wire-runtime/src/jvmMain/kotlin/com/squareup/wire/internal/SealedOneOfBinding.kt diff --git a/wire-compiler/src/main/java/com/squareup/wire/WireCompiler.kt b/wire-compiler/src/main/java/com/squareup/wire/WireCompiler.kt index 2de83e6fb8..968aad3274 100644 --- a/wire-compiler/src/main/java/com/squareup/wire/WireCompiler.kt +++ b/wire-compiler/src/main/java/com/squareup/wire/WireCompiler.kt @@ -73,7 +73,7 @@ import okio.openZip * (default) to generate Kotlin enum classes, or `sealed_class` to generate sealed classes with * data objects for each value and an Unrecognized case. * - * `--kotlin_oneof_mode` controls how Kotlin oneofs are generated. Valid values are `flat` + * `--kotlin_oneof_mode` controls how oneofs are generated in Kotlin. Valid values are `flat` * (default) to generate each oneof field as a separate nullable property, `boxed` to generate * boxed oneofs, or `sealed_class` to generate a nested sealed class with a data class per field. * diff --git a/wire-golden-files/src/main/kotlin/squareup/wire/sealedoneof/SealedOneOfs.kt b/wire-golden-files/src/main/kotlin/squareup/wire/sealedoneof/SealedOneOfs.kt index a9c6c2f0a5..5f9fd19c5f 100644 --- a/wire-golden-files/src/main/kotlin/squareup/wire/sealedoneof/SealedOneOfs.kt +++ b/wire-golden-files/src/main/kotlin/squareup/wire/sealedoneof/SealedOneOfs.kt @@ -14,6 +14,7 @@ import com.squareup.wire.ProtoReader import com.squareup.wire.ProtoWriter import com.squareup.wire.ReverseProtoWriter import com.squareup.wire.Syntax.PROTO_2 +import com.squareup.wire.WireOneofField import com.squareup.wire.`internal`.JvmField import com.squareup.wire.`internal`.JvmSynthetic import kotlin.Any @@ -92,10 +93,20 @@ public class SealedOneOfs private constructor( */ @OneofOptionOption(33) public sealed class Value { + @WireOneofField( + tag = 1, + adapter = "squareup.wire.sealedoneof.SealedMessage#ADAPTER", + declaredName = "first_value", + ) public data class FirstValue( public val `value`: SealedMessage, ) : SealedOneOfs.Value() + @WireOneofField( + tag = 2, + adapter = "com.squareup.wire.ProtoAdapter#STRING", + declaredName = "second_value", + ) @Deprecated(message = "second_value is deprecated") public data class SecondValue( public val `value`: String, @@ -104,6 +115,11 @@ public class SealedOneOfs private constructor( /** * This one is a good candidate. */ + @WireOneofField( + tag = 3, + adapter = "com.squareup.wire.ProtoAdapter#INT32", + declaredName = "value", + ) @FieldOptionOption(806) public data class Value( public val `value`: Int, diff --git a/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinGenerator.kt b/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinGenerator.kt index 9403b9b890..e7e098116f 100644 --- a/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinGenerator.kt +++ b/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinGenerator.kt @@ -74,7 +74,9 @@ import com.squareup.wire.WireEnclosingType import com.squareup.wire.WireEnum import com.squareup.wire.WireEnumConstant import com.squareup.wire.WireField +import com.squareup.wire.WireOneofField import com.squareup.wire.WireRpc +import com.squareup.wire.WireSealedOneof import com.squareup.wire.internal.DoubleArrayList import com.squareup.wire.internal.FloatArrayList import com.squareup.wire.internal.IntArrayList @@ -1212,13 +1214,17 @@ class KotlinGenerator private constructor( schemaIndex = schemaIndex++, ), ) - is OneOf -> result.add( - constructorParameterAndProperty( - message = message, - oneOf = fieldOrOneOf, - nameAllocator = nameAllocator, - ), - ) + is OneOf -> { + val sealedIndex = if (fieldOrOneOf in message.sealedOneOfs()) schemaIndex++ else null + result.add( + constructorParameterAndProperty( + message = message, + oneOf = fieldOrOneOf, + nameAllocator = nameAllocator, + schemaIndex = sealedIndex, + ), + ) + } else -> throw IllegalArgumentException("Unexpected element: $fieldOrOneOf") } } @@ -1309,6 +1315,7 @@ class KotlinGenerator private constructor( message: MessageType, oneOf: OneOf, nameAllocator: NameAllocator, + schemaIndex: Int? = null, ): Pair { val fieldClass = message.oneOfClassFor(oneOf, nameAllocator) val fieldName = nameAllocator[oneOf] @@ -1321,6 +1328,14 @@ class KotlinGenerator private constructor( .initializer(CodeBlock.of(if (buildersOnly) "builder.%N" else "%N", fieldName)) .jvmFieldIf(useJavaInterop, jvmAnnotationPackage) .apply { + if (!buildersOnly && schemaIndex != null) { + addAnnotation( + AnnotationSpec.builder(WireSealedOneof::class) + .useSiteTarget(AnnotationSpec.UseSiteTarget.FIELD) + .addMember("schemaIndex = %L", schemaIndex) + .build(), + ) + } if (oneOf.documentation.isNotBlank()) { addKdoc("%L\n", oneOf.documentation.sanitizeKdoc()) } @@ -3039,6 +3054,7 @@ class KotlinGenerator private constructor( .build(), ) .apply { + addAnnotation(wireOneofFieldAnnotation(field)) if (field.isDeprecated) { addAnnotation( AnnotationSpec.builder(Deprecated::class) @@ -3216,6 +3232,19 @@ class KotlinGenerator private constructor( .build() } + private fun wireOneofFieldAnnotation(field: Field): AnnotationSpec = AnnotationSpec.builder(WireOneofField::class) + .addMember("tag = %L", field.tag) + .addMember("adapter = %S", field.type!!.adapterString()) + .apply { + if (field.isRedacted) addMember("redacted = true") + val jsonName = field.jsonName + if (jsonName != null && jsonName != field.name) { + addMember("jsonName = %S", jsonName) + } + } + .addMember("declaredName = %S", field.name) + .build() + private fun MessageType.fieldsAndFlatOneOfFieldsAndBoxedOneOfs(): List { val fieldsAndFlatOneOfFields: List = declaredFields + extensionFields + flatOneOfs().flatMap { it.fields } diff --git a/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinGeneratorTest.kt b/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinGeneratorTest.kt index 462c277d4f..6b5c8104c9 100644 --- a/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinGeneratorTest.kt +++ b/wire-kotlin-generator/src/test/java/com/squareup/wire/kotlin/KotlinGeneratorTest.kt @@ -2217,16 +2217,27 @@ class KotlinGeneratorTest { assertThat(code).contains( """ |public class PaymentMethodChoice( + | @field:WireSealedOneof(schemaIndex = 0) | public val method: Method? = null, """.trimMargin(), ) assertThat(code).contains( """ | public sealed class Method { + | @WireOneofField( + | tag = 1, + | adapter = "com.squareup.wire.ProtoAdapter#STRING", + | declaredName = "card_id", + | ) | public data class CardId( | public val `value`: String, | ) : Method() | + | @WireOneofField( + | tag = 2, + | adapter = "com.squareup.wire.ProtoAdapter#STRING", + | declaredName = "cash", + | ) | public data class Cash( | public val `value`: String, | ) : Method() @@ -2252,12 +2263,12 @@ class KotlinGeneratorTest { } val code = KotlinWithProfilesGenerator(schema) .generateKotlin("PaymentMethodChoice", oneofMode = OneofMode.SEALED_CLASS) - // encode: when over the sealed class + // Encode. assertThat(code).contains("when (val method = value.method)") assertThat(code).contains("is Method.CardId ->") assertThat(code).contains("is Method.Cash ->") assertThat(code).contains("null -> {}") - // decode: inline tag cases + // Decode. assertThat(code).contains("1 -> method = Method.CardId(") assertThat(code).contains("2 -> method = Method.Cash(") } @@ -2334,10 +2345,8 @@ class KotlinGeneratorTest { } val code = KotlinWithProfilesGenerator(schema) .generateKotlin("PaymentMethodChoice", oneofMode = OneofMode.SEALED_CLASS) - // @SensitiveOption(true) should appear on CardId but not on Cash assertThat(code).contains("@SensitiveOption(true)") assertThat(code).contains("public data class CardId(") - // Cash has no option applied assertThat(code).contains("public data class Cash(") } diff --git a/wire-protoc-compatibility-tests/build.gradle.kts b/wire-protoc-compatibility-tests/build.gradle.kts index 3f244fadbb..8544770431 100644 --- a/wire-protoc-compatibility-tests/build.gradle.kts +++ b/wire-protoc-compatibility-tests/build.gradle.kts @@ -38,6 +38,25 @@ wire { ) } + kotlin { + buildersOnly = true + oneofMode = "sealed_class" + + includes = listOf( + "squareup.proto2.kotlin.sealed.interop.buildersonly.*", + "squareup.proto3.kotlin.sealed.interop.buildersonly.*" + ) + } + + kotlin { + oneofMode = "sealed_class" + + includes = listOf( + "squareup.proto2.kotlin.sealed.interop.*", + "squareup.proto3.kotlin.sealed.interop.*" + ) + } + kotlin { javaInterop = true boxOneOfsMinSize = 5 diff --git a/wire-protoc-compatibility-tests/src/main/proto/squareup/proto2/kotlin/interop/interop_sealed_test.proto b/wire-protoc-compatibility-tests/src/main/proto/squareup/proto2/kotlin/interop/interop_sealed_test.proto new file mode 100644 index 0000000000..09d6ffc798 --- /dev/null +++ b/wire-protoc-compatibility-tests/src/main/proto/squareup/proto2/kotlin/interop/interop_sealed_test.proto @@ -0,0 +1,37 @@ +/* + * Copyright 2026 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 + * + * http://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. + */ +syntax = "proto2"; + +package squareup.proto2.kotlin.sealed.interop; + +message InteropSealedOneOf { + oneof second_method { + bytes e = 7; + sint32 f = 6; + SealedMessage g = 5; + } + optional string h = 8; + oneof first_method { + string a = 3 [json_name = "sayMyName"]; + int32 b = 2; + bool c = 1; + int64 d = 4; + } + + message SealedMessage { + optional string content = 1; + } +} diff --git a/wire-protoc-compatibility-tests/src/main/proto/squareup/proto2/kotlin/interop/interop_sealed_test_buildersonly.proto b/wire-protoc-compatibility-tests/src/main/proto/squareup/proto2/kotlin/interop/interop_sealed_test_buildersonly.proto new file mode 100644 index 0000000000..c56e21e2cc --- /dev/null +++ b/wire-protoc-compatibility-tests/src/main/proto/squareup/proto2/kotlin/interop/interop_sealed_test_buildersonly.proto @@ -0,0 +1,37 @@ +/* + * Copyright 2026 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 + * + * http://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. + */ +syntax = "proto2"; + +package squareup.proto2.kotlin.sealed.interop.buildersonly; + +message InteropSealedOneOfBuildersOnly { + oneof second_method { + bytes e = 7; + sint32 f = 6; + SealedMessage g = 5; + } + optional string h = 8; + oneof first_method { + string a = 3 [json_name = "sayMyName"]; + int32 b = 2; + bool c = 1; + int64 d = 4; + } + + message SealedMessage { + optional string content = 1; + } +} diff --git a/wire-protoc-compatibility-tests/src/main/proto/squareup/proto2/kotlin/interop/interop_test.proto b/wire-protoc-compatibility-tests/src/main/proto/squareup/proto2/kotlin/interop/interop_test.proto index eab1899920..6bf756742c 100644 --- a/wire-protoc-compatibility-tests/src/main/proto/squareup/proto2/kotlin/interop/interop_test.proto +++ b/wire-protoc-compatibility-tests/src/main/proto/squareup/proto2/kotlin/interop/interop_test.proto @@ -56,6 +56,22 @@ message InteropBoxOneOf { } } +message InteropSealedOneOf { + oneof method { + string a = 1; + int32 b = 2; + bool c = 3; + int64 d = 4; + bytes e = 5; + sint32 f = 6; + SealedMessage g = 7; + } + + message SealedMessage { + optional string content = 1; + } +} + message SubInteropRepeatedUint { repeated uint32 repeated_values = 1 [packed = true]; } diff --git a/wire-protoc-compatibility-tests/src/main/proto/squareup/proto3/kotlin/interop/interop_sealed_test.proto b/wire-protoc-compatibility-tests/src/main/proto/squareup/proto3/kotlin/interop/interop_sealed_test.proto new file mode 100644 index 0000000000..01d1b84d5f --- /dev/null +++ b/wire-protoc-compatibility-tests/src/main/proto/squareup/proto3/kotlin/interop/interop_sealed_test.proto @@ -0,0 +1,37 @@ +/* + * Copyright 2026 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 + * + * http://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. + */ +syntax = "proto3"; + +package squareup.proto3.kotlin.sealed.interop; + +message InteropSealedOneOf { + oneof second_method { + bytes e = 7; + sint32 f = 6; + SealedMessage g = 5; + } + string h = 8; + oneof first_method { + string a = 3 [json_name = "sayMyName"]; + int32 b = 2; + bool c = 1; + int64 d = 4; + } + + message SealedMessage { + string content = 1; + } +} diff --git a/wire-protoc-compatibility-tests/src/main/proto/squareup/proto3/kotlin/interop/interop_sealed_test_buildersonly.proto b/wire-protoc-compatibility-tests/src/main/proto/squareup/proto3/kotlin/interop/interop_sealed_test_buildersonly.proto new file mode 100644 index 0000000000..8a8bc4cd6a --- /dev/null +++ b/wire-protoc-compatibility-tests/src/main/proto/squareup/proto3/kotlin/interop/interop_sealed_test_buildersonly.proto @@ -0,0 +1,37 @@ +/* + * Copyright 2026 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 + * + * http://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. + */ +syntax = "proto3"; + +package squareup.proto3.kotlin.sealed.interop.buildersonly; + +message InteropSealedOneOfBuildersOnly { + oneof second_method { + bytes e = 7; + sint32 f = 6; + SealedMessage g = 5; + } + string h = 8; + oneof first_method { + string a = 3 [json_name = "sayMyName"]; + int32 b = 2; + bool c = 1; + int64 d = 4; + } + + message SealedMessage { + string content = 1; + } +} diff --git a/wire-protoc-compatibility-tests/src/test/java/com/squareup/wire/InteropChecker.kt b/wire-protoc-compatibility-tests/src/test/java/com/squareup/wire/InteropChecker.kt index ecdc081c21..18a587f15d 100644 --- a/wire-protoc-compatibility-tests/src/test/java/com/squareup/wire/InteropChecker.kt +++ b/wire-protoc-compatibility-tests/src/test/java/com/squareup/wire/InteropChecker.kt @@ -17,13 +17,15 @@ package com.squareup.wire import assertk.assertThat import assertk.assertions.isEqualTo -import assertk.assertions.message +import assertk.assertions.support.expected import com.google.gson.GsonBuilder import com.google.gson.TypeAdapter import com.google.protobuf.Message import com.google.protobuf.util.JsonFormat import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi +import com.squareup.wire.json.assertJsonEquals +import okio.Buffer import okio.ByteString import okio.ByteString.Companion.toByteString @@ -95,8 +97,8 @@ class InteropChecker( @Suppress("UNCHECKED_CAST") val wireBytes = (adapter as ProtoAdapter).encodeByteString(message) - assertThat(wireBytes).isEqualTo(protocBytes) - assertThat(adapter.encodeByteString(message)).isEqualTo(protocBytes) + assertProtobufByteStringEquality(expected = protocBytes!!, actual = wireBytes) + assertProtobufByteStringEquality(expected = adapter.encodeByteString(message), actual = wireBytes) assertThat(adapter.decode(protocBytes!!)).isEqualTo(message) } @@ -104,7 +106,7 @@ class InteropChecker( @Suppress("UNCHECKED_CAST") val adapter = gson.getAdapter(message::class.java) as TypeAdapter - assertThat(adapter.toJson(message)).isEqualTo(wireCanonicalJson) + assertJsonEquals(expected = wireCanonicalJson, value = adapter.toJson(message)) val fromJson = adapter.fromJson(wireCanonicalJson) assertThat(fromJson, displayActual = { """ @@ -122,7 +124,7 @@ class InteropChecker( @Suppress("UNCHECKED_CAST") val adapter = moshi.adapter(message::class.java) as JsonAdapter - assertThat(adapter.toJson(message)).isEqualTo(wireCanonicalJson) + assertJsonEquals(expected = wireCanonicalJson, value = adapter.toJson(message)) val fromJson = adapter.fromJson(wireCanonicalJson) assertThat(fromJson, displayActual = { """ @@ -135,4 +137,47 @@ class InteropChecker( assertThat(adapter.fromJson(json)).isEqualTo(message) } } + + /** + * Protoc and Wire might serialize their Protobuf messages in a different order. We thus try two + * comparisons. + */ + fun assertProtobufByteStringEquality(expected: ByteString, actual: ByteString) { + if (actual == expected) return + + val sortedActual = actual.sortedByTagUnsafe() + if (sortedActual == expected) return + + assertThat(actual).expected(":<$expected> but was:<$actual> or sorted by tags: <$sortedActual>") + } +} + +/** + * Not safe for production code. Returns a re-encoded copy of this protobuf message with fields + * ordered by tag number. Fields with the same tag (e.g. repeated fields) retain their relative + * order. Nested message bytes are kept opaque. This is used solely for testing. + */ +private fun ByteString.sortedByTagUnsafe(): ByteString { + data class Record(val tag: Int, val fieldEncoding: FieldEncoding, val value: Any) + + val records = mutableListOf() + val reader = ProtoReader(Buffer().write(this)) + + reader.forEachTag { tag -> + val fieldEncoding = reader.peekFieldEncoding()!! + + @Suppress("UNCHECKED_CAST") + val value = (fieldEncoding.rawProtoAdapter() as ProtoAdapter).decode(reader) + records.add(Record(tag, fieldEncoding, value)) + } + + records.sortBy { it.tag } + + val buffer = Buffer() + val writer = ProtoWriter(buffer) + for ((tag, fieldEncoding, value) in records) { + @Suppress("UNCHECKED_CAST") + (fieldEncoding.rawProtoAdapter() as ProtoAdapter).encodeWithTag(writer, tag, value) + } + return buffer.readByteString() } diff --git a/wire-protoc-compatibility-tests/src/test/java/com/squareup/wire/InteropTest.kt b/wire-protoc-compatibility-tests/src/test/java/com/squareup/wire/InteropTest.kt index 292948c63c..689af5b06d 100644 --- a/wire-protoc-compatibility-tests/src/test/java/com/squareup/wire/InteropTest.kt +++ b/wire-protoc-compatibility-tests/src/test/java/com/squareup/wire/InteropTest.kt @@ -38,6 +38,8 @@ import squareup.proto2.kotlin.interop.InteropCamelCase as InteropCamelCaseK2 import squareup.proto2.kotlin.interop.InteropDuration as InteropDurationK2 import squareup.proto2.kotlin.interop.InteropJsonName as InteropJsonNameK2 import squareup.proto2.kotlin.interop.InteropUint64 as InteropUint64K2 +import squareup.proto2.kotlin.sealed.interop.InteropSealedOneOf as InteropSealedOneOfK2 +import squareup.proto2.kotlin.sealed.interop.buildersonly.InteropSealedOneOfBuildersOnly as InteropSealedOneOfBuildersOnlyK2 import squareup.proto3.java.interop.InteropBoxOneOf as InteropBoxOneOfJ3 import squareup.proto3.java.interop.InteropCamelCase as InteropCamelCaseJ3 import squareup.proto3.java.interop.InteropDuration as InteropDurationJ3 @@ -61,6 +63,10 @@ import squareup.proto3.kotlin.interop.InteropRepeatedEnums as InteropRepeatedEnu import squareup.proto3.kotlin.interop.InteropUint64 as InteropUint64K3 import squareup.proto3.kotlin.interop.InteropWrappers as InteropWrappersK3 import squareup.proto3.kotlin.interop.TestProto3Optional.InteropOptional as InteropOptionalP3 +import squareup.proto3.kotlin.sealed.interop.InteropSealedOneOf as InteropSealedOneOfK3 +import squareup.proto3.kotlin.sealed.interop.InteropSealedTest.InteropSealedOneOf as InteropSealedOneOfP3 +import squareup.proto3.kotlin.sealed.interop.buildersonly.InteropSealedOneOfBuildersOnly as InteropSealedOneOfBuildersOnlyK3 +import squareup.proto3.kotlin.sealed.interop.buildersonly.InteropSealedTestBuildersonly.InteropSealedOneOfBuildersOnly as InteropSealedOneOfBuildersOnlyP3 import squareup.proto3.kotlin.unrecognized_constant.Easter as EasterK3 import squareup.proto3.kotlin.unrecognized_constant.EasterOuterClass.Easter as EasterP3 @@ -279,6 +285,56 @@ class InteropTest { ) } + @Test fun sealedOneOfsKotlin_BuildersOnly() { + val checker = InteropChecker( + protocMessage = InteropSealedOneOfBuildersOnlyP3.newBuilder() + .setA("Hello") + .setG(InteropSealedOneOfBuildersOnlyP3.SealedMessage.newBuilder().setContent("content").build()) + .setH("in the middle") + .build(), + canonicalJson = """{"sayMyName":"Hello","g":{"content":"content"},"h":"in the middle"}""", + ) + checker.check( + InteropSealedOneOfBuildersOnlyK2.Builder() + .first_method(InteropSealedOneOfBuildersOnlyK2.First_method.A("Hello")) + .h("in the middle") + .second_method(InteropSealedOneOfBuildersOnlyK2.Second_method.G(InteropSealedOneOfBuildersOnlyK2.SealedMessage.build { content("content") })) + .build(), + ) + checker.check( + InteropSealedOneOfBuildersOnlyK3.Builder() + .first_method(InteropSealedOneOfBuildersOnlyK3.First_method.A("Hello")) + .h("in the middle") + .second_method(InteropSealedOneOfBuildersOnlyK3.Second_method.G(InteropSealedOneOfBuildersOnlyK3.SealedMessage.build { content("content") })) + .build(), + ) + } + + @Test fun sealedOneOfsKotlin() { + val checker = InteropChecker( + protocMessage = InteropSealedOneOfP3.newBuilder() + .setA("Hello") + .setG(InteropSealedOneOfP3.SealedMessage.newBuilder().setContent("content").build()) + .setH("in the middle") + .build(), + canonicalJson = """{"sayMyName":"Hello","g":{"content":"content"},"h":"in the middle"}""", + ) + checker.check( + InteropSealedOneOfK2( + first_method = InteropSealedOneOfK2.First_method.A("Hello"), + h = "in the middle", + second_method = InteropSealedOneOfK2.Second_method.G(InteropSealedOneOfK2.SealedMessage(content = "content")), + ), + ) + checker.check( + InteropSealedOneOfK3( + first_method = InteropSealedOneOfK3.First_method.A("Hello"), + h = "in the middle", + second_method = InteropSealedOneOfK3.Second_method.G(InteropSealedOneOfK3.SealedMessage(content = "content")), + ), + ) + } + @Ignore("Needs to implement boxed oneofs in Java.") @Test fun boxOneOfsJava() { diff --git a/wire-runtime/api/wire-runtime.api b/wire-runtime/api/wire-runtime.api index 6dfdc2e757..4c164c15e8 100644 --- a/wire-runtime/api/wire-runtime.api +++ b/wire-runtime/api/wire-runtime.api @@ -423,6 +423,14 @@ public final class com/squareup/wire/WireField$Label : java/lang/Enum { public static fun values ()[Lcom/squareup/wire/WireField$Label; } +public abstract interface annotation class com/squareup/wire/WireOneofField : java/lang/annotation/Annotation { + public abstract fun adapter ()Ljava/lang/String; + public abstract fun declaredName ()Ljava/lang/String; + public abstract fun jsonName ()Ljava/lang/String; + public abstract fun redacted ()Z + public abstract fun tag ()I +} + public abstract interface annotation class com/squareup/wire/WireRpc : java/lang/annotation/Annotation { public abstract fun path ()Ljava/lang/String; public abstract fun requestAdapter ()Ljava/lang/String; @@ -430,3 +438,7 @@ public abstract interface annotation class com/squareup/wire/WireRpc : java/lang public abstract fun sourceFile ()Ljava/lang/String; } +public abstract interface annotation class com/squareup/wire/WireSealedOneof : java/lang/annotation/Annotation { + public abstract fun schemaIndex ()I +} + diff --git a/wire-runtime/src/commonMain/kotlin/com/squareup/wire/WireOneofField.kt b/wire-runtime/src/commonMain/kotlin/com/squareup/wire/WireOneofField.kt new file mode 100644 index 0000000000..831b3d33e1 --- /dev/null +++ b/wire-runtime/src/commonMain/kotlin/com/squareup/wire/WireOneofField.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2026 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.wire + +/** + * Annotates generated data classes inside a sealed-class oneof with metadata for serialization and + * deserialization. Each variant data class carries its own tag, adapter, and name metadata, + * discovered at runtime via reflection on the sealed class's nested classes. + */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class WireOneofField( + /** The tag number used to store the field's value. */ + val tag: Int, + /** + * Reference to the static field that holds a [ProtoAdapter] that can encode and decode this + * field's values. The reference is a string like `com.squareup.wire.protos.person.Person#ADAPTER` + * and contains a fully-qualified class name followed by a hash symbol and a field name. + */ + val adapter: String, + /** + * Name of this field as declared in the proto schema. This value is set to a non-empty string + * only when the declared name differs from the generated one; for instance, a proto field named + * `final` generated in Java will be renamed to `final_`. + */ + val declaredName: String = "", + /** + * Redacted fields are omitted from toString() to protect sensitive data. Defaults to false. + */ + val redacted: Boolean = false, + /** + * Name representing this field as it should be used in JSON. This value is set to a non-empty + * string only when the json name differs from the name as declared in the proto schema. + */ + val jsonName: String = "", +) diff --git a/wire-runtime/src/commonMain/kotlin/com/squareup/wire/WireSealedOneof.kt b/wire-runtime/src/commonMain/kotlin/com/squareup/wire/WireSealedOneof.kt new file mode 100644 index 0000000000..c93e231b61 --- /dev/null +++ b/wire-runtime/src/commonMain/kotlin/com/squareup/wire/WireSealedOneof.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 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.wire + +/** + * Annotates a sealed class generated for a oneof with its position in the enclosing message's + * primary constructor. This is used by [com.squareup.wire.KotlinConstructorBuilder] to reconstruct + * the message when no explicit `Builder` class is present (i.e. `javaInterop = false`). + */ +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +annotation class WireSealedOneof( + /** + * This is the order that this oneof was declared in the `.proto` schema, counting all + * constructor parameters (regular fields and sealed oneofs). Used to reconstruct the correct + * constructor call order. + */ + val schemaIndex: Int, +) diff --git a/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/KotlinConstructorBuilder.kt b/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/KotlinConstructorBuilder.kt index 232c05151e..ad36df3a0a 100644 --- a/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/KotlinConstructorBuilder.kt +++ b/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/KotlinConstructorBuilder.kt @@ -24,6 +24,7 @@ internal class KotlinConstructorBuilder, B : Message.Builder> private val repeatedFieldValueMap: MutableMap>> private val mapFieldKeyValueMap: MutableMap>> + private val sealedOneofValues: MutableMap = LinkedHashMap() init { val fieldCount = messageType.declaredFields.size @@ -52,6 +53,12 @@ internal class KotlinConstructorBuilder, B : Message.Builder, B : Message.Builder().apply { - for (protoField in protoFields) { - add(protoField) - } - } + data class ConstructorParam(val type: Class<*>, val schemaIndex: Int, val value: () -> Any?) // Retrieve constructor explicitly since `Constructor#getParameterCount` was introduced in JDK // 1.8 and may not be available. ByteString is for `unknown_fields`. - val parameterTypes = protoFields.map { it.type }.toTypedArray() - val constructor = messageType.getDeclaredConstructor(*parameterTypes, ByteString::class.java) - val args = (0..parameterTypes.size).map { index -> - when { - index == protoFields.size -> buildUnknownFields() - else -> get(fields.removeFirst().wireField) + val params = messageType.declaredFields.mapNotNull { field -> + val wireField = field.getAnnotation(WireField::class.java) + if (wireField != null) { + return@mapNotNull ConstructorParam(field.type, wireField.schemaIndex) { get(wireField) } } - } - return constructor.newInstance(*args.toTypedArray()) as M - } - - private fun Class.declaredProtoFields(): List = declaredFields - .mapNotNull { field -> - val wireField = field.declaredAnnotations.filterIsInstance() - .firstOrNull() - return@mapNotNull wireField?.let { ProtoField(field.type, wireField) } - } - .sortedBy { it.wireField.schemaIndex } + val sealedOneof = field.getAnnotation(WireSealedOneof::class.java) + if (sealedOneof != null) { + val name = field.name + return@mapNotNull ConstructorParam(field.type, sealedOneof.schemaIndex) { sealedOneofValues[name] } + } + null + }.sortedBy { it.schemaIndex } - private class ProtoField( - val type: Class<*>, - val wireField: WireField, - ) + val constructor = messageType.getDeclaredConstructor( + *(params.map { it.type } + ByteString::class.java).toTypedArray(), + ) + val args = (params.map { it.value() } + buildUnknownFields()).toTypedArray() + return constructor.newInstance(*args) as M + } } private val WireField.isMap: Boolean diff --git a/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/internal/SealedOneOfBinding.kt b/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/internal/SealedOneOfBinding.kt new file mode 100644 index 0000000000..9816938a6c --- /dev/null +++ b/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/internal/SealedOneOfBinding.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2026 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.wire.internal + +import com.squareup.wire.KotlinConstructorBuilder +import com.squareup.wire.Message +import com.squareup.wire.ProtoAdapter +import com.squareup.wire.WireField +import com.squareup.wire.WireOneofField +import java.lang.reflect.Field + +internal class SealedOneOfBinding, B : Message.Builder>( + private val messageField: Field, + builderType: Class, + private val annotation: WireOneofField, + private val subclassType: Class<*>, + classLoader: ClassLoader? = messageField.declaringClass.classLoader, +) : FieldOrOneOfBinding() { + + init { + messageField.isAccessible = true + } + + // When there is no explicit Builder (javaInterop = false), KotlinConstructorBuilder is used. + // It has no message-specific fields, so we store sealed oneof values in its side-map instead. + private val isKotlinConstructorBuilder: Boolean = + builderType == KotlinConstructorBuilder::class.java + + private val builderField: Field? = if (isKotlinConstructorBuilder) { + null + } else { + builderType.getDeclaredField(messageField.name).also { it.isAccessible = true } + } + + /** @see WireOneofField.tag */ + override val tag: Int get() = annotation.tag + + /** Sealed oneof fields are always optional. Equivalent to [WireField.Label.OPTIONAL]. */ + override val label: WireField.Label get() = WireField.Label.OPTIONAL + + /** @see WireOneofField.redacted */ + override val redacted: Boolean get() = annotation.redacted + + /** @see WireOneofField.jsonName */ + override val wireFieldJsonName: String get() = annotation.jsonName + + /** @see WireOneofField.declaredName */ + override val name: String get() = annotation.declaredName + + /** @see WireOneofField.declaredName */ + override val declaredName: String get() = annotation.declaredName + + override val isMap: Boolean get() = false + override val isMessage: Boolean + get() = Message::class.java.isAssignableFrom(singleAdapter.type?.javaObjectType) + override val keyAdapter: ProtoAdapter<*> get() = error("not a map") + + /** @see WireOneofField.adapter */ + @Suppress("UNCHECKED_CAST") + override val singleAdapter: ProtoAdapter<*> = + ProtoAdapter.get(annotation.adapter, classLoader) as ProtoAdapter + override val writeIdentityValues: Boolean get() = false + + private val valueField: Field by lazy { + subclassType.getDeclaredField("value").also { it.isAccessible = true } + } + + override fun get(message: M): Any? { + val sealed = messageField.get(message) ?: return null + if (!subclassType.isInstance(sealed)) return null + return valueField.get(sealed) + } + + override fun getFromBuilder(builder: B): Any? { + val sealed = if (isKotlinConstructorBuilder) { + @Suppress("UNCHECKED_CAST") + (builder as KotlinConstructorBuilder).getSealedOneof(messageField.name) + } else { + builderField!!.get(builder) + } ?: return null + if (!subclassType.isInstance(sealed)) return null + return valueField.get(sealed) + } + + override fun set(builder: B, value: Any?) { + if (value == null) return + val ctor = subclassType.declaredConstructors.first { it.parameterCount == 1 } + ctor.isAccessible = true + val sealed = ctor.newInstance(value) + if (isKotlinConstructorBuilder) { + @Suppress("UNCHECKED_CAST") + (builder as KotlinConstructorBuilder).setSealedOneof(messageField.name, sealed) + } else { + builderField!!.set(builder, sealed) + } + } + + override fun value(builder: B, value: Any) = set(builder, value) +} diff --git a/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/internal/reflection.kt b/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/internal/reflection.kt index 442dbd91b3..498abd9737 100644 --- a/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/internal/reflection.kt +++ b/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/internal/reflection.kt @@ -22,6 +22,7 @@ import com.squareup.wire.OneOf import com.squareup.wire.ProtoAdapter import com.squareup.wire.Syntax import com.squareup.wire.WireField +import com.squareup.wire.WireOneofField import java.lang.reflect.Field import java.util.Collections import kotlin.reflect.KClass @@ -63,6 +64,10 @@ fun , B : Message.Builder> createRuntimeMessageAdapter( for (key in getKeys(messageField)) { fields[key.tag] = OneOfBinding(messageField, builderType, key, writeIdentityValues) } + } else { + for ((annotation, subclassType) in getSealedOneOfAnnotations(messageField)) { + fields[annotation.tag] = SealedOneOfBinding(messageField, builderType, annotation, subclassType, classLoader) + } } } @@ -89,6 +94,14 @@ private fun , B : Message.Builder> getKeys( return keysField.get(null) as Set> } +private fun getSealedOneOfAnnotations(messageField: Field): List>> { + val sealedClass = messageField.type ?: return emptyList() + return sealedClass.declaredClasses.mapNotNull { nestedClass -> + val annotation = nestedClass.getAnnotation(WireOneofField::class.java) ?: return@mapNotNull null + annotation to nestedClass + } +} + fun , B : Message.Builder> createRuntimeMessageAdapter( messageType: Class, writeIdentityValues: Boolean, From a74b16132b9f686bd3b0eaa97024acb80a78b32b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Quenaudon?= Date: Wed, 29 Apr 2026 15:45:52 +0100 Subject: [PATCH 5/5] WireCompiler update --- docs/wire_compiler.md | 22 +++++++++- .../squareup/wire/kotlin/KotlinGenerator.kt | 44 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/docs/wire_compiler.md b/docs/wire_compiler.md index e088adfe0d..da5220ccdf 100644 --- a/docs/wire_compiler.md +++ b/docs/wire_compiler.md @@ -489,12 +489,12 @@ wire { // `suspending` to generate coroutines APIs that require a Kotlin // coroutines context. // `blocking` to generate blocking APIs callable by Java and Kotlin. - rpcCallStyle = 'blocking' + rpcCallStyle = 'suspending' // `client` to generate interfaces best suited to sending outbound calls. // `server` to generate interfaces best suited to receiving inbound calls. // `none` to not generate services. - rpcRole = 'server' + rpcRole = 'client' // If set, the value will be appended to generated service type names. If // null, their rpcRole will be used as a suffix instead. @@ -515,9 +515,27 @@ wire { // Defines how an protobuf enum type is to be generated. See `com.squareup.wire.kotlin.EnumMode` enumMode = "enum_class" + // `flat` (default) to generate each oneof field as a separate nullable property on the + // message class. `boxed` to generate all oneof fields as boxed types. `sealed_class` to + // generate a nested sealed class with a data class subtype per field. + // See `com.squareup.wire.kotlin.OneofMode`. + oneofMode = "flat" + // True to emit a adapters that include a decode() function that accepts a `ProtoReader32`. // Use this optimization when targeting Kotlin/JS, where `Long` cursors are inefficient. emitProtoReader32 = false + + // True for the generated classes to be mutable. + mutableTypes = false + + // True for the generated gRPC client to use explicit classes for client, server, + // and bidirectional streaming calls. + explicitStreamingCalls = false + + // False to skip making immutable copies of repeated and map fields when constructing a + // Message. Only set to false when you can guarantee these fields won't be mutated after + // construction, to avoid the copy overhead. + makeImmutableCopies = true } } ``` diff --git a/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinGenerator.kt b/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinGenerator.kt index e7e098116f..845351c18c 100644 --- a/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinGenerator.kt +++ b/wire-kotlin-generator/src/main/java/com/squareup/wire/kotlin/KotlinGenerator.kt @@ -147,6 +147,50 @@ class KotlinGenerator private constructor( private val explicitStreamingCalls: Boolean, private val makeImmutableCopies: Boolean, ) { + @Deprecated(level = DeprecationLevel.HIDDEN, message = "Obsolete, for compiled code before oneofMode was added.") + private constructor( + schema: Schema, + typeToKotlinName: Map, + memberToKotlinName: Map, + profile: Profile, + emitAndroid: Boolean, + javaInterOp: Boolean, + emitDeclaredOptions: Boolean, + emitAppliedOptions: Boolean, + rpcCallStyle: RpcCallStyle, + rpcRole: RpcRole, + boxOneOfsMinSize: Int, + nameSuffix: String?, + buildersOnly: Boolean, + escapeKotlinKeywords: Boolean, + enumMode: EnumMode, + emitProtoReader32: Boolean, + mutableTypes: Boolean, + explicitStreamingCalls: Boolean, + makeImmutableCopies: Boolean, + ) : this( + schema = schema, + typeToKotlinName = typeToKotlinName, + memberToKotlinName = memberToKotlinName, + profile = profile, + emitAndroid = emitAndroid, + javaInterOp = javaInterOp, + emitDeclaredOptions = emitDeclaredOptions, + emitAppliedOptions = emitAppliedOptions, + rpcCallStyle = rpcCallStyle, + rpcRole = rpcRole, + boxOneOfsMinSize = boxOneOfsMinSize, + nameSuffix = nameSuffix, + buildersOnly = buildersOnly, + escapeKotlinKeywords = escapeKotlinKeywords, + enumMode = enumMode, + oneofMode = OneofMode.FLAT, + emitProtoReader32 = emitProtoReader32, + mutableTypes = mutableTypes, + explicitStreamingCalls = explicitStreamingCalls, + makeImmutableCopies = makeImmutableCopies, + ) + private val nameAllocatorStore = mutableMapOf() private val sealedSubclassNameAllocatorStore = mutableMapOf()