Skip to content

Commit

Permalink
Allows 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 Kotlin#397 Make skipping null values default behavior for protobuf
Closes Kotlin#71 NULLs are not supported when writing to protobuf
  • Loading branch information
Sven Obser committed Jan 9, 2020
1 parent 75061d3 commit 5f7b7db
Show file tree
Hide file tree
Showing 6 changed files with 415 additions and 6 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,17 @@ 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 {
class ProtoBuf(
context: SerialModule = EmptyModule,
private val encodeDefaults: Boolean = true
) : 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 @@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/*
* Copyright 2017-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
*/

package kotlinx.serialization.formats.protobuf

import kotlinx.serialization.Serializable
import kotlinx.serialization.dump
import kotlinx.serialization.formats.IMessage
import kotlinx.serialization.formats.dumpCompare
import kotlinx.serialization.formats.proto.TestData
import kotlinx.serialization.formats.readCompare
import kotlinx.serialization.load
import kotlinx.serialization.protobuf.ProtoBuf
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

/**
* Test that [ProtoBuf] works correctly if [ProtoBuf.encodeDefaults] is set to `false`
* and that `null` is allowed as a default value.
*
* In this case `null` values should not get encoded into bytes. This allows to check if an optional
* property was set or not (like it is possible with the *Java ProtoBuf library*).
*/
class ProtoBufNullTest {

/** ProtoBuf instance that does **not** encode defaults. */
private val protoBuf = ProtoBuf(encodeDefaults = false)

@Test
fun readCompareWithNulls() {
val data = MessageWithOptionals()
assertTrue(readCompare(data, alwaysPrint = true, protoBuf = protoBuf))
}

@Test
fun dumpCompareWithNulls() {
val data = MessageWithOptionals()
assertTrue(dumpCompare(data, alwaysPrint = true, protoBuf = protoBuf))
}

@Test
fun readCompareWithDefaults() {
val data = MessageWithOptionals(0, "", MessageWithOptionals.Position.FIRST, 99, listOf(1, 2, 3))
assertTrue(readCompare(data, alwaysPrint = true, protoBuf = protoBuf))
}

@Test
fun dumpCompareWithDefaults() {
val data = MessageWithOptionals(0, "", MessageWithOptionals.Position.FIRST, 99, listOf(1, 2, 3))
assertTrue(dumpCompare(data, alwaysPrint = true, protoBuf = protoBuf))
}

@Test
fun readCompareWithValues() {
val data = MessageWithOptionals(42, "Test", MessageWithOptionals.Position.SECOND, 24, listOf(1, 2, 3))
assertTrue(readCompare(data, alwaysPrint = true, protoBuf = protoBuf))
}

@Test
fun dumpCompareWithValues() {
val data = MessageWithOptionals(42, "Test", MessageWithOptionals.Position.SECOND, 24, listOf(1, 2, 3))
assertTrue(dumpCompare(data, alwaysPrint = true, protoBuf = protoBuf))
}

@Test
fun testThatNullValuesAreNotEncoded() {
val data = MessageWithOptionals()
val parsed = TestData.MessageWithOptionals.parseFrom(protoBuf.dump(data))

assertFalse(parsed.hasA(), "Expected that null value for optional property `a` is not encoded.")
assertFalse(parsed.hasB(), "Expected that null value for optional property `b` is not encoded.")
assertFalse(parsed.hasC(), "Expected that null value for optional property `c` is not encoded.")
assertFalse(parsed.hasD(), "Expected that null value for optional property `d` is not encoded.")

assertEquals(0, parsed.a, "Expected default value for property `a`.")
assertEquals("", parsed.b, "Expected default value for property `b`.")
assertEquals(TestData.MessageWithOptionals.Position.FIRST, parsed.c, "Expected default value for property `c`.")
assertEquals(99, parsed.d, "Expected default value for property `d`.")
assertEquals(emptyList(), parsed.eList, "Expected default value for property `e`.")
}

@Test
fun testThatDefaultValuesAreEncodedCorrectly() {
val data = MessageWithOptionals(0, "", MessageWithOptionals.Position.FIRST, 99, emptyList())
val parsed = TestData.MessageWithOptionals.parseFrom(protoBuf.dump(data))

assertTrue(parsed.hasA(), "Expected that custom value for optional property `a` is encoded.")
assertTrue(parsed.hasB(), "Expected that custom value for optional property `b` is encoded.")
assertTrue(parsed.hasC(), "Expected that custom value for optional property `c` is encoded.")
assertTrue(parsed.hasD(), "Expected that custom value for optional property `d` is encoded.")

assertEquals(0, parsed.a, "Expected custom value for property `a`.")
assertEquals("", parsed.b, "Expected custom value for property `b`.")
assertEquals(TestData.MessageWithOptionals.Position.FIRST, parsed.c, "Expected custom value for property `c`.")
assertEquals(99, parsed.d, "Expected custom value for property `d`.")
assertEquals(emptyList(), parsed.eList, "Expected custom value for property `e`.")
}

@Test
fun testThatCustomValuesAreEncodedCorrectly() {
val data = MessageWithOptionals(42, "Test", MessageWithOptionals.Position.SECOND, 24, listOf(1, 2, 3))
val parsed = TestData.MessageWithOptionals.parseFrom(protoBuf.dump(data))

assertTrue(parsed.hasA(), "Expected that custom value for optional property `a` is encoded.")
assertTrue(parsed.hasB(), "Expected that custom value for optional property `b` is encoded.")
assertTrue(parsed.hasC(), "Expected that custom value for optional property `c` is encoded.")
assertTrue(parsed.hasD(), "Expected that custom value for optional property `d` is encoded.")

assertEquals(42, parsed.a, "Expected custom value for property `a`.")
assertEquals("Test", parsed.b, "Expected custom value for property `b`.")
assertEquals(TestData.MessageWithOptionals.Position.SECOND, parsed.c, "Expected custom value for property `c`.")
assertEquals(24, parsed.d, "Expected custom value for property `d`.")
assertEquals(listOf(1, 2, 3), parsed.eList, "Expected custom value for property `e`.")
}

@Test
fun testThatNullValuesAreNotDecoded() {
val data = TestData.MessageWithOptionals.newBuilder().build()
val parsed = protoBuf.load<MessageWithOptionals>(data.toByteArray())

assertFalse(parsed.hasA(), "Expected that null value for optional property `a` is not decoded.")
assertFalse(parsed.hasB(), "Expected that null value for optional property `b` is not decoded.")
assertFalse(parsed.hasC(), "Expected that null value for optional property `c` is not decoded.")
assertFalse(parsed.hasD(), "Expected that null value for optional property `d` is not decoded.")

assertEquals(0, parsed.a, "Expected default value for property `a`.")
assertEquals("", parsed.b, "Expected default value for property `b`.")
assertEquals(MessageWithOptionals.Position.FIRST, parsed.c, "Expected default value for property `c`.")
assertEquals(99, parsed.d, "Expected default value for property `d`.")
assertEquals(emptyList(), parsed.e, "Expected default value for property `e`.")
}

@Test
fun testThatDefaultValuesAreDecodedCorrectly() {
val data = TestData.MessageWithOptionals.newBuilder()
.setA(0)
.setB("")
.setC(TestData.MessageWithOptionals.Position.FIRST)
.setD(99)
.addAllE(emptyList())
.build()
val parsed = protoBuf.load<MessageWithOptionals>(data.toByteArray())

assertTrue(parsed.hasA(), "Expected that custom value for optional property `a` is decoded.")
assertTrue(parsed.hasB(), "Expected that custom value for optional property `b` is decoded.")
assertTrue(parsed.hasC(), "Expected that custom value for optional property `c` is decoded.")
assertTrue(parsed.hasD(), "Expected that custom value for optional property `d` is decoded.")

assertEquals(0, parsed.a, "Expected custom value for property `a`.")
assertEquals("", parsed.b, "Expected custom value for property `b`.")
assertEquals(MessageWithOptionals.Position.FIRST, parsed.c, "Expected custom value for property `c`.")
assertEquals(99, parsed.d, "Expected custom value for property `d`.")
assertEquals(emptyList(), parsed.e, "Expected custom value for property `e`.")
}

@Test
fun testThatCustomValuesAreDecodedCorrectly() {
val data = TestData.MessageWithOptionals.newBuilder()
.setA(42)
.setB("Test")
.setC(TestData.MessageWithOptionals.Position.SECOND)
.setD(24)
.addAllE(listOf(1, 2, 3))
.build()
val parsed = protoBuf.load<MessageWithOptionals>(data.toByteArray())

assertTrue(parsed.hasA(), "Expected that custom value for optional property `a` is decoded.")
assertTrue(parsed.hasB(), "Expected that custom value for optional property `b` is decoded.")
assertTrue(parsed.hasC(), "Expected that custom value for optional property `c` is decoded.")
assertTrue(parsed.hasD(), "Expected that custom value for optional property `d` is decoded.")

assertEquals(42, parsed.a, "Expected custom value for property `a`.")
assertEquals("Test", parsed.b, "Expected custom value for property `b`.")
assertEquals(MessageWithOptionals.Position.SECOND, parsed.c, "Expected custom value for property `c`.")
assertEquals(24, parsed.d, "Expected custom value for property `d`.")
assertEquals(listOf(1, 2, 3), parsed.e, "Expected custom value for property `e`.")
}

/**
* Test [Serializable] that manually implements `TestOptional` defined in `test_data.proto`.
*
* Using `null` as default values allows to implement [hasA], ... according to Java ProtoBuf library.
*/
@Serializable
private data class MessageWithOptionals(
private val _a: Int? = null,
private val _b: String? = null,
private val _c: Position? = null,
private val _d: Int? = null,
private val _e: List<Int>? = null
) : IMessage {

val a: Int
get() = _a ?: 0

val b: String
get() = _b ?: ""

val c: Position
get() = _c ?: Position.FIRST

val d: Int
get() = _d ?: 99

val e: List<Int>
get() = _e ?: emptyList()

fun hasA() = _a != null

fun hasB() = _b != null

fun hasC() = _c != null

fun hasD() = _d != null

/**
* Convert this [Serializable] object to its expected [TestData.MessageWithOptionals] ProtoBuf message.
*
* For this test we expect that `null` values are not encoded.
*/
override fun toProtobufMessage(): TestData.MessageWithOptionals =
TestData.MessageWithOptionals.newBuilder().also { builder ->
if (_a != null) builder.a = _a
if (_b != null) builder.b = _b
if (_c != null) builder.c = _c.toProtoBuf()
if (_d != null) builder.d = _d
if (_e != null) builder.addAllE(_e)
}.build()

enum class Position {
FIRST, SECOND;

fun toProtoBuf() = when (this) {
FIRST -> TestData.MessageWithOptionals.Position.FIRST
SECOND -> TestData.MessageWithOptionals.Position.SECOND
}
}
}
}
Loading

0 comments on commit 5f7b7db

Please sign in to comment.