Skip to content

Commit

Permalink
Allow to set ProtoBuf.shouldEncodeElementDefault to false
Browse files Browse the repository at this point in the history
Porting a project using the *Java ProtoBuf library* to *kotlinx.serialization* can become quite hard since optional properties are not supported in the same way.

On the one hand it is not possible to omit optional properties during serialization (this is possible by not setting an optional property while building the message in Java).
On the other hand it is not possible to check if a property has been omitted by the sender during de-serialization (this is possible via `Message.hasA()` in Java).

Allowing to set `ProtoBuf.shouldEncodeElementDefault()` to `false` allows to create `@Serializable data classes` that support optional properties during serialization and de-serialization. Additionally, it makes `null` values possible for optional properties as well.

Closes #397 Make skipping null values default behavior for protobuf
Closes #71 NULLs are not supported when writing to protobuf
  • Loading branch information
Sven Obser authored and sandwwraith committed Jan 20, 2020
1 parent dc3f467 commit a24cc92
Show file tree
Hide file tree
Showing 7 changed files with 495 additions and 7 deletions.
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ buildscript {
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
classpath "com.jfrog.bintray.gradle:gradle-bintray-plugin:$bintray_version"

// Protobuf is udes in JVM tests
// Protobuf is used in JVM tests
classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.8'

// Various benchmarking stuff
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,96 @@ import kotlinx.serialization.protobuf.ProtoBuf.Varint.decodeSignedVarintLong
import kotlinx.serialization.protobuf.ProtoBuf.Varint.decodeVarint
import kotlinx.serialization.protobuf.ProtoBuf.Varint.encodeVarint

class ProtoBuf(context: SerialModule = EmptyModule) : AbstractSerialFormat(context), BinaryFormat {
/**
* The main entry point to work with ProtoBuf serialization.
* It is typically used by constructing an application-specific instance, with configured ProtoBuf-specific behaviour
* ([encodeDefaults] constructor parameter) and, if necessary, registered
* custom serializers (in [SerialModule] provided by [context] constructor parameter).
*
* ## Usage Example
* Given a ProtoBuf definition with one required field, one optional field and one optional field with a custom default
* value:
* ```
* message MyMessage {
* required int32 first = 1;
* optional int32 second = 2;
* optional int32 third = 3 [default = 42];
* }
* ```
*
* The corresponding [Serializable] class should match the ProtoBuf definition and should use the same default values:
* ```
* @Serializable
* data class MyMessage(val first: Int, val second: Int = 0, val third: Int = 42)
*
* // Serialize to ProtoBuf hex string
* val encoded = ProtoBuf.dumps(MyMessage.serializer(), MyMessage(15)) // "080f1000182a"
*
* // Deserialize from ProtoBuf hex string
* val decoded = ProtoBuf.loads<MyMessage>(MyMessage.serializer(), encoded) // MyMessage(first=15, second=0, third=42)
*
* // Serialize to ProtoBuf bytes (omitting default values)
* val encoded2 = ProtoBuf(encodeDefaults = false).dump(MyMessage.serializer(), MyMessage(15)) // [0x08, 0x0f]
*
* // Deserialize ProtoBuf bytes will use default values of the MyMessage class
* val decoded2 = ProtoBuf.load<MyMessage>(MyMessage.serializer(), encoded2) // MyMessage(first=15, second=0, third=42)
* ```
*
* ### Check existence of optional fields
* Null values can be used as default value for optional fields to implement more complex use-cases that rely on
* checking if a field was set or not. This requires the use of a custom ProtoBuf instance with
* `ProtoBuf(encodeDefaults = false)`.
*
* ```
* @Serializable
* data class MyMessage(val first: Int, private val _second: Int? = null, private val _third: Int? = null) {
*
* val second: Int
* get() = _second ?: 0
*
* val third: Int
* get() = _third ?: 42
*
* fun hasSecond() = _second != null
*
* fun hasThird() = _third != null
* }
*
* // Serialize to ProtoBuf bytes (encodeDefaults=false is required if null values are used)
* val encoded = ProtoBuf(encodeDefaults = false).dump(MyMessage(15)) // [0x08, 0x0f]
*
* // Deserialize ProtoBuf bytes
* val decoded = ProtoBuf.load<MyMessage>(MyMessage.serializer(), encoded) // MyMessage(first=15, _second=null, _third=null)
* decoded.hasSecond() // false
* decoded.second // 0
* decoded.hasThird() // false
* decoded.third // 42
*
* // Serialize to ProtoBuf bytes
* val encoded2 = ProtoBuf(encodeDefaults = false).dumps(MyMessage.serializer(), MyMessage(15, 0, 0)) // [0x08, 0x0f, 0x10, 0x00, 0x18, 0x00]
*
* // Deserialize ProtoBuf bytes
* val decoded2 = ProtoBuf.loads<MyMessage>(MyMessage.serializer(), encoded2) // MyMessage(first=15, _second=0, _third=0)
* decoded.hasSecond() // true
* decoded.second // 0
* decoded.hasThird() // true
* decoded.third // 0
* ```
*
* @param encodeDefaults specifies whether default values are encoded.
* @param context application-specific [SerialModule] to provide custom serializers.
*/
class ProtoBuf(
val encodeDefaults: Boolean = true,
context: SerialModule = EmptyModule
) : AbstractSerialFormat(context), BinaryFormat {

internal open inner class ProtobufWriter(val encoder: ProtobufEncoder) : TaggedEncoder<ProtoDesc>() {
public override val context
get() = this@ProtoBuf.context

override fun shouldEncodeElementDefault(desc: SerialDescriptor, index: Int): Boolean = encodeDefaults

override fun beginStructure(desc: SerialDescriptor, vararg typeParams: KSerializer<*>): CompositeEncoder = when (desc.kind) {
StructureKind.LIST -> RepeatedWriter(encoder, currentTag)
StructureKind.CLASS, UnionKind.OBJECT, is PolymorphicKind -> ObjectWriter(currentTagOrNull, encoder)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class ProtobufPolymorphismTest {
133
)
)
assertSerializedToBinaryAndRestored(obj, PolyBox.serializer(), ProtoBuf(SimplePolymorphicModule))
assertSerializedToBinaryAndRestored(obj, PolyBox.serializer(), ProtoBuf(context = SimplePolymorphicModule))
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,24 @@ fun GeneratedMessageV3.toHex(): String {
return (HexConverter.printHexBinary(b.toByteArray(), lowerCase = true))
}

inline fun <reified T : IMessage> dumpCompare(it: T, alwaysPrint: Boolean = false): Boolean {
/**
* Check serialization of [ProtoBuf].
*
* 1. Serializes the given [IMessage] into bytes using [ProtoBuf].
* 2. Parses those bytes via the `Java ProtoBuf library`.
* 3. Compares parsed `Java ProtoBuf object` to expected object ([IMessage.toProtobufMessage]).
*
* @param it The [IMessage] to check.
* @param alwaysPrint Set to `true` if expected/found objects should always get printed to console (default: `false`).
* @param protoBuf Provide custom [ProtoBuf] instance (default: [ProtoBuf.plain]).
*
* @return `true` if the de-serialization returns the expected object.
*/
inline fun <reified T : IMessage> dumpCompare(it: T, alwaysPrint: Boolean = false, protoBuf: ProtoBuf = ProtoBuf.plain): Boolean {
val msg = it.toProtobufMessage()
var parsed: GeneratedMessageV3?
val c = try {
val bytes = ProtoBuf.dump(it)
val bytes = protoBuf.dump(it)
if (alwaysPrint) println("Serialized bytes: ${HexConverter.printHexBinary(bytes)}")
parsed = msg.parserForType.parseFrom(bytes)
msg == parsed
Expand All @@ -42,12 +55,25 @@ inline fun <reified T : IMessage> dumpCompare(it: T, alwaysPrint: Boolean = fals
return c
}

inline fun <reified T : IMessage> readCompare(it: T, alwaysPrint: Boolean = false): Boolean {
/**
* Check de-serialization of [ProtoBuf].
*
* 1. Converts expected `Java ProtoBuf object` ([IMessage.toProtobufMessage]) to bytes.
* 2. Parses those bytes via [ProtoBuf].
* 3. Compares parsed ProtoBuf object to given object.
*
* @param it The [IMessage] to check.
* @param alwaysPrint Set to `true` if expected/found objects should always get printed to console (default: `false`).
* @param protoBuf Provide custom [ProtoBuf] instance (default: [ProtoBuf.plain]).
*
* @return `true` if the de-serialization returns the original object.
*/
inline fun <reified T : IMessage> readCompare(it: T, alwaysPrint: Boolean = false, protoBuf: ProtoBuf = ProtoBuf.plain): Boolean {
var obj: T?
val c = try {
val msg = it.toProtobufMessage()
val hex = msg.toHex()
obj = ProtoBuf.loads<T>(hex)
obj = protoBuf.loads<T>(hex)
obj == it
} catch (e: Exception) {
obj = null
Expand Down
Loading

0 comments on commit a24cc92

Please sign in to comment.