Skip to content

Commit 251bca7

Browse files
Support oneof declaraction in protobuf (#2546)
With `@ProtoOneOf` annotation for sealed classes and interfaces. Inheritors of such an interface are expected to have one property with @ProtoNumber, encoded and decoded with special `OneOfEncoder/Decoder`. See documentation for design details. Fixes #2538 Fixes #67
1 parent f525f1a commit 251bca7

25 files changed

+1646
-290
lines changed

docs/formats.md

Lines changed: 113 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ stable, these are currently experimental features of Kotlin Serialization.
1818
* [Integer types](#integer-types)
1919
* [Lists as repeated fields](#lists-as-repeated-fields)
2020
* [Packed fields](#packed-fields)
21+
* [Oneof field (experimental)](#oneof-field-experimental)
22+
* [Usage](#usage)
23+
* [Alternative](#alternative)
2124
* [ProtoBuf schema generator (experimental)](#protobuf-schema-generator-experimental)
2225
* [Properties (experimental)](#properties-experimental)
2326
* [Custom formats (experimental)](#custom-formats-experimental)
@@ -435,6 +438,106 @@ Per the standard packed fields can only be used on primitive numeric types. The
435438
Per the [format description](https://developers.google.com/protocol-buffers/docs/encoding#packed) the parser ignores
436439
the annotation, but rather reads list in either packed or repeated format.
437440

441+
### Oneof field (experimental)
442+
443+
Kotlin Serialization `ProtoBuf` format supports [oneof](https://protobuf.dev/programming-guides/proto2/#oneof) fields
444+
basing on the [Polymorphism](polymorphism.md) functionality.
445+
446+
#### Usage
447+
448+
Given a protobuf message defined like:
449+
450+
```proto
451+
message Data {
452+
required string name = 1;
453+
oneof phone {
454+
string home_phone = 2;
455+
string work_phone = 3;
456+
}
457+
}
458+
```
459+
460+
You can define a kotlin class semantically equal to this message by following these steps:
461+
462+
* Declare a sealed interface or abstract class, to represent of the `oneof` group, called *the oneof interface*. In our example, oneof interface is `IPhoneType`.
463+
* Declare a Kotlin class as usual to represent the whole message (`class Data` in our example). In this class, add the property with oneof interface type, annotated with `@ProtoOneOf`. Do not use `@ProtoNumber` for that property.
464+
* Declare subclasses for oneof interface, one per each oneof group element. Each class must have **exactly one property** with the corresponding oneof element type. In our example, these classes are `HomePhone` and `WorkPhone`.
465+
* Annotate properties in subclasses with `@ProtoNumber`, according to original `oneof` definition. In our example, `val number: String` in `HomePhone` has `@ProtoNumber(2)` annotation, because of field `string home_phone = 2;` in `oneof phone`.
466+
467+
<!--- INCLUDE
468+
import kotlinx.serialization.*
469+
import kotlinx.serialization.protobuf.*
470+
-->
471+
472+
```kotlin
473+
// The outer class
474+
@Serializable
475+
data class Data(
476+
@ProtoNumber(1) val name: String,
477+
@ProtoOneOf val phone: IPhoneType?,
478+
)
479+
480+
// The oneof interface
481+
@Serializable sealed interface IPhoneType
482+
483+
// Message holder for home_phone
484+
@Serializable @JvmInline value class HomePhone(@ProtoNumber(2) val number: String): IPhoneType
485+
486+
// Message holder for work_phone. Can also be a value class, but we leave it as `data` to demonstrate that both variants can be used.
487+
@Serializable data class WorkPhone(@ProtoNumber(3) val number: String): IPhoneType
488+
489+
fun main() {
490+
val dataTom = Data("Tom", HomePhone("123"))
491+
val stringTom = ProtoBuf.encodeToHexString(dataTom)
492+
val dataJerry = Data("Jerry", WorkPhone("789"))
493+
val stringJerry = ProtoBuf.encodeToHexString(dataJerry)
494+
println(stringTom)
495+
println(stringJerry)
496+
println(ProtoBuf.decodeFromHexString<Data>(stringTom))
497+
println(ProtoBuf.decodeFromHexString<Data>(stringJerry))
498+
}
499+
```
500+
501+
> You can get the full code [here](../guide/example/example-formats-08.kt).
502+
503+
```text
504+
0a03546f6d1203313233
505+
0a054a657272791a03373839
506+
Data(name=Tom, phone=HomePhone(number=123))
507+
Data(name=Jerry, phone=WorkPhone(number=789))
508+
```
509+
510+
<!--- TEST -->
511+
512+
In [ProtoBuf diagnostic mode](https://protogen.marcgravell.com/decode) the first 2 lines in the output are equivalent to
513+
514+
```
515+
Field #1: 0A String Length = 3, Hex = 03, UTF8 = "Tom" Field #2: 12 String Length = 3, Hex = 03, UTF8 = "123"
516+
Field #1: 0A String Length = 5, Hex = 05, UTF8 = "Jerry" Field #3: 1A String Length = 3, Hex = 03, UTF8 = "789"
517+
```
518+
519+
You should note that each group of `oneof` types should be tied to exactly one data class, and it is better not to reuse it in
520+
another data class. Otherwise, you may get id conflicts or `IllegalArgumentException` in runtime.
521+
522+
#### Alternative
523+
524+
You don't always need to apply the `@ProtoOneOf` form in your class for messages with `oneof` fields, if this class is only used for deserialization.
525+
526+
For example, the following class:
527+
528+
```
529+
@Serializable
530+
data class Data2(
531+
@ProtoNumber(1) val name: String,
532+
@ProtoNumber(2) val homeNumber: String? = null,
533+
@ProtoNumber(3) val workNumber: String? = null,
534+
)
535+
```
536+
537+
is also compatible with the `message Data` given above, which means the same input can be deserialized into it instead of `Data` — in case you don't want to deal with sealed hierarchies.
538+
539+
But please note that there are no exclusivity checks. This means that if an instance of `Data2` has both (or none) `homeNumber` and `workNumber` as non-null values and is serialized to protobuf, it no longer complies with the original schema. If you send such data to another parser, one of the fields may be omitted, leading to an unknown issue.
540+
438541
### ProtoBuf schema generator (experimental)
439542

440543
As mentioned above, when working with protocol buffers you usually use a ".proto" file and a code generator for your
@@ -467,15 +570,15 @@ fun main() {
467570
println(schemas)
468571
}
469572
```
470-
> You can get the full code [here](../guide/example/example-formats-08.kt).
573+
> You can get the full code [here](../guide/example/example-formats-09.kt).
471574
472575
Which would output as follows.
473576

474577
```text
475578
syntax = "proto2";
476579
477580
478-
// serial name 'example.exampleFormats08.SampleData'
581+
// serial name 'example.exampleFormats09.SampleData'
479582
message SampleData {
480583
required int64 amount = 1;
481584
optional string description = 2;
@@ -519,7 +622,7 @@ fun main() {
519622
}
520623
```
521624

522-
> You can get the full code [here](../guide/example/example-formats-09.kt).
625+
> You can get the full code [here](../guide/example/example-formats-10.kt).
523626
524627
The resulting map has dot-separated keys representing keys of the nested objects.
525628

@@ -599,7 +702,7 @@ fun main() {
599702
}
600703
```
601704

602-
> You can get the full code [here](../guide/example/example-formats-10.kt).
705+
> You can get the full code [here](../guide/example/example-formats-11.kt).
603706
604707
As a result, we got all the primitive values in our object graph visited and put into a list
605708
in _serial_ order.
@@ -701,7 +804,7 @@ fun main() {
701804
}
702805
```
703806

704-
> You can get the full code [here](../guide/example/example-formats-11.kt).
807+
> You can get the full code [here](../guide/example/example-formats-12.kt).
705808
706809
Now we can convert a list of primitives back to an object tree.
707810

@@ -792,7 +895,7 @@ fun main() {
792895
}
793896
-->
794897

795-
> You can get the full code [here](../guide/example/example-formats-12.kt).
898+
> You can get the full code [here](../guide/example/example-formats-13.kt).
796899
797900
<!--- TEST
798901
[kotlinx.serialization, kotlin, 9000]
@@ -899,7 +1002,7 @@ fun main() {
8991002
}
9001003
```
9011004

902-
> You can get the full code [here](../guide/example/example-formats-13.kt).
1005+
> You can get the full code [here](../guide/example/example-formats-14.kt).
9031006
9041007
We see the size of the list added to the result, letting the decoder know where to stop.
9051008

@@ -1011,7 +1114,7 @@ fun main() {
10111114

10121115
```
10131116

1014-
> You can get the full code [here](../guide/example/example-formats-14.kt).
1117+
> You can get the full code [here](../guide/example/example-formats-15.kt).
10151118
10161119
In the output we see how not-null`!!` and `NULL` marks are used.
10171120

@@ -1139,7 +1242,7 @@ fun main() {
11391242
}
11401243
```
11411244
1142-
> You can get the full code [here](../guide/example/example-formats-15.kt).
1245+
> You can get the full code [here](../guide/example/example-formats-16.kt).
11431246
11441247
As we can see, the result is a dense binary format that only contains the data that is being serialized.
11451248
It can be easily tweaked for any kind of domain-specific compact encoding.
@@ -1333,7 +1436,7 @@ fun main() {
13331436
}
13341437
```
13351438
1336-
> You can get the full code [here](../guide/example/example-formats-16.kt).
1439+
> You can get the full code [here](../guide/example/example-formats-17.kt).
13371440
13381441
As we can see, our custom byte array format is being used, with the compact encoding of its size in one byte.
13391442

docs/serialization-guide.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ Once the project is set up, we can start serializing some classes.
152152
* <a name='integer-types'></a>[Integer types](formats.md#integer-types)
153153
* <a name='lists-as-repeated-fields'></a>[Lists as repeated fields](formats.md#lists-as-repeated-fields)
154154
* <a name='packed-fields'></a>[Packed fields](formats.md#packed-fields)
155+
* <a name='oneof-field-experimental'></a>[Oneof field (experimental)](formats.md#oneof-field-experimental)
156+
* <a name='usage'></a>[Usage](formats.md#usage)
157+
* <a name='alternative'></a>[Alternative](formats.md#alternative)
155158
* <a name='protobuf-schema-generator-experimental'></a>[ProtoBuf schema generator (experimental)](formats.md#protobuf-schema-generator-experimental)
156159
* <a name='properties-experimental'></a>[Properties (experimental)](formats.md#properties-experimental)
157160
* <a name='custom-formats-experimental'></a>[Custom formats (experimental)](formats.md#custom-formats-experimental)

formats/protobuf/api/kotlinx-serialization-protobuf.api

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ public synthetic class kotlinx/serialization/protobuf/ProtoNumber$Impl : kotlinx
3939
public final synthetic fun number ()I
4040
}
4141

42+
public abstract interface annotation class kotlinx/serialization/protobuf/ProtoOneOf : java/lang/annotation/Annotation {
43+
}
44+
45+
public synthetic class kotlinx/serialization/protobuf/ProtoOneOf$Impl : kotlinx/serialization/protobuf/ProtoOneOf {
46+
public fun <init> ()V
47+
}
48+
4249
public abstract interface annotation class kotlinx/serialization/protobuf/ProtoPacked : java/lang/annotation/Annotation {
4350
}
4451

formats/protobuf/api/kotlinx-serialization-protobuf.klib.api

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ open annotation class kotlinx.serialization.protobuf/ProtoNumber : kotlin/Annota
3333
final val number // kotlinx.serialization.protobuf/ProtoNumber.number|{}number[0]
3434
final fun <get-number>(): kotlin/Int // kotlinx.serialization.protobuf/ProtoNumber.number.<get-number>|<get-number>(){}[0]
3535
}
36+
open annotation class kotlinx.serialization.protobuf/ProtoOneOf : kotlin/Annotation { // kotlinx.serialization.protobuf/ProtoOneOf|null[0]
37+
constructor <init>() // kotlinx.serialization.protobuf/ProtoOneOf.<init>|<init>(){}[0]
38+
}
3639
open annotation class kotlinx.serialization.protobuf/ProtoPacked : kotlin/Annotation { // kotlinx.serialization.protobuf/ProtoPacked|null[0]
3740
constructor <init>() // kotlinx.serialization.protobuf/ProtoPacked.<init>|<init>(){}[0]
3841
}

formats/protobuf/commonMain/src/kotlinx/serialization/protobuf/ProtoTypes.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,14 @@ public annotation class ProtoType(public val type: ProtoIntegerType)
5353
@Target(AnnotationTarget.PROPERTY)
5454
@ExperimentalSerializationApi
5555
public annotation class ProtoPacked
56+
57+
/**
58+
* Instructs that a particular property should be written as an [oneof](https://protobuf.dev/programming-guides/proto2/#oneof).
59+
*
60+
* The type of the annotated property should be polymorphic (interface or abstract class).
61+
* Inheritors of this type would represent `one of` choices, and each inheritor should have exactly one property, annotated with [ProtoNumber].
62+
*/
63+
@SerialInfo
64+
@Target(AnnotationTarget.PROPERTY)
65+
@ExperimentalSerializationApi
66+
public annotation class ProtoOneOf

formats/protobuf/commonMain/src/kotlinx/serialization/protobuf/internal/Helpers.kt

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package kotlinx.serialization.protobuf.internal
88

99
import kotlinx.serialization.*
1010
import kotlinx.serialization.descriptors.*
11+
import kotlinx.serialization.modules.*
1112
import kotlinx.serialization.protobuf.*
1213

1314
internal typealias ProtoDesc = Long
@@ -16,19 +17,17 @@ internal const val i64 = 1
1617
internal const val SIZE_DELIMITED = 2
1718
internal const val i32 = 5
1819

19-
private const val INTTYPEMASK = (Int.MAX_VALUE.toLong() shr 1) shl 33
20-
private const val PACKEDMASK = 1L shl 32
20+
internal const val ID_HOLDER_ONE_OF = -2
2121

22-
@Suppress("NOTHING_TO_INLINE")
23-
internal inline fun ProtoDesc(protoId: Int, type: ProtoIntegerType, packed: Boolean): ProtoDesc {
24-
val packedBits = if (packed) 1L shl 32 else 0L
25-
val signature = type.signature or packedBits
26-
return signature or protoId.toLong()
27-
}
22+
private const val ONEOFMASK = 1L shl 36
23+
private const val INTTYPEMASK = 3L shl 33
24+
private const val PACKEDMASK = 1L shl 32
2825

2926
@Suppress("NOTHING_TO_INLINE")
30-
internal inline fun ProtoDesc(protoId: Int, type: ProtoIntegerType): ProtoDesc {
31-
return type.signature or protoId.toLong()
27+
internal inline fun ProtoDesc(protoId: Int, type: ProtoIntegerType, packed: Boolean = false, oneOf: Boolean = false): ProtoDesc {
28+
val packedBits = if (packed) PACKEDMASK else 0L
29+
val oneOfBits = if (oneOf) ONEOFMASK else 0L
30+
return packedBits or oneOfBits or type.signature or protoId.toLong()
3231
}
3332

3433
internal inline val ProtoDesc.protoId: Int get() = (this and Int.MAX_VALUE.toLong()).toInt()
@@ -51,11 +50,19 @@ internal val SerialDescriptor.isPackable: Boolean
5150
internal val ProtoDesc.isPacked: Boolean
5251
get() = (this and PACKEDMASK) != 0L
5352

53+
internal val ProtoDesc.isOneOf: Boolean
54+
get() = (this and ONEOFMASK) != 0L
55+
56+
internal fun ProtoDesc.overrideId(protoId: Int): ProtoDesc {
57+
return this and (0xFFFFFFF00000000L) or protoId.toLong()
58+
}
59+
5460
internal fun SerialDescriptor.extractParameters(index: Int): ProtoDesc {
5561
val annotations = getElementAnnotations(index)
5662
var protoId: Int = index + 1
5763
var format: ProtoIntegerType = ProtoIntegerType.DEFAULT
5864
var protoPacked = false
65+
var isOneOf = false
5966

6067
for (i in annotations.indices) { // Allocation-friendly loop
6168
val annotation = annotations[i]
@@ -65,23 +72,61 @@ internal fun SerialDescriptor.extractParameters(index: Int): ProtoDesc {
6572
format = annotation.type
6673
} else if (annotation is ProtoPacked) {
6774
protoPacked = true
75+
} else if (annotation is ProtoOneOf) {
76+
isOneOf = true
6877
}
6978
}
70-
return ProtoDesc(protoId, format, protoPacked)
79+
if (isOneOf) {
80+
// reset protoId to index-based for oneOf field,
81+
// Decoder will restore the real proto id then from [ProtobufDecoder.index2IdMap]
82+
// See [kotlinx.serialization.protobuf.internal.ProtobufDecoder.decodeElementIndex] for detail
83+
protoId = index + 1
84+
}
85+
return ProtoDesc(protoId, format, protoPacked, isOneOf)
7186
}
7287

88+
/**
89+
* Get the proto id from the descriptor of [index] element,
90+
* or return [ID_HOLDER_ONE_OF] if such element is marked with [ProtoOneOf]
91+
*/
7392
internal fun extractProtoId(descriptor: SerialDescriptor, index: Int, zeroBasedDefault: Boolean): Int {
7493
val annotations = descriptor.getElementAnnotations(index)
94+
var result = if (zeroBasedDefault) index else index + 1
7595
for (i in annotations.indices) { // Allocation-friendly loop
7696
val annotation = annotations[i]
77-
if (annotation is ProtoNumber) {
78-
return annotation.number
97+
if (annotation is ProtoOneOf) {
98+
// Fast return for one of field
99+
return ID_HOLDER_ONE_OF
100+
} else if (annotation is ProtoNumber) {
101+
result = annotation.number
79102
}
80103
}
81-
return if (zeroBasedDefault) index else index + 1
104+
return result
82105
}
83106

84107
internal class ProtobufDecodingException(message: String) : SerializationException(message)
85108

86109
internal expect fun Int.reverseBytes(): Int
87110
internal expect fun Long.reverseBytes(): Long
111+
112+
113+
internal fun SerialDescriptor.getAllOneOfSerializerOfField(
114+
serializersModule: SerializersModule,
115+
): List<SerialDescriptor> {
116+
return when (this.kind) {
117+
PolymorphicKind.OPEN -> serializersModule.getPolymorphicDescriptors(this)
118+
PolymorphicKind.SEALED -> getElementDescriptor(1).elementDescriptors.toList()
119+
else -> throw IllegalArgumentException("Class ${this.serialName} should be abstract or sealed or interface to be used as @ProtoOneOf property.")
120+
}.onEach { desc ->
121+
if (desc.getElementAnnotations(0).none { anno -> anno is ProtoNumber }) {
122+
throw IllegalArgumentException("${desc.serialName} implementing oneOf type ${this.serialName} should have @ProtoNumber annotation in its single property.")
123+
}
124+
}
125+
}
126+
127+
internal fun SerialDescriptor.getActualOneOfSerializer(
128+
serializersModule: SerializersModule,
129+
protoId: Int
130+
): SerialDescriptor? {
131+
return getAllOneOfSerializerOfField(serializersModule).find { it.extractParameters(0).protoId == protoId }
132+
}

0 commit comments

Comments
 (0)