Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Serialize sealed classes #271

Merged
merged 23 commits into from
Apr 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6a08e16
Done on Android
iruizmar Mar 19, 2022
37bf8ca
Added on iOS and js
iruizmar Mar 20, 2022
983702f
Add else branches
iruizmar Mar 20, 2022
5d6edd5
Keep format
iruizmar Mar 20, 2022
de286d5
Format
iruizmar Mar 20, 2022
5e88cc2
Format
iruizmar Mar 20, 2022
2bb2f07
Add testing
iruizmar Mar 21, 2022
9392ccd
Merge branch 'master' into serialize_sealed_classes
nbransby Mar 21, 2022
161f8db
Merge branch 'master' into serialize_sealed_classes
nbransby Mar 21, 2022
6f69066
Remove logging
iruizmar Mar 21, 2022
99a83a1
Merge branch 'serialize_sealed_classes' of https://github.com/iruizma…
iruizmar Mar 21, 2022
1cc612f
Merge branch 'master' into serialize_sealed_classes
iruizmar Mar 22, 2022
7a6d815
Add polymorphic serialization
iruizmar Mar 24, 2022
acb9a79
Merge branches 'serialize_sealed_classes' and 'serialize_sealed_class…
iruizmar Mar 24, 2022
477a10e
Cleanup
iruizmar Mar 24, 2022
747f98e
Merge branch 'master' into serialize_sealed_classes
iruizmar Mar 24, 2022
ae01585
Add polymorphic serialization section to Readme
iruizmar Mar 24, 2022
c82bf3c
Merge branch 'serialize_sealed_classes' of https://github.com/iruizma…
iruizmar Mar 24, 2022
2516beb
Add sample of @FirebaseClassDiscriminator usage
iruizmar Mar 25, 2022
a2f6b68
Fix sample
iruizmar Mar 25, 2022
df30acb
Merge branch 'master' into serialize_sealed_classes
iruizmar Apr 4, 2022
a9a2051
Add link to kotlinx.serialization
iruizmar Apr 6, 2022
8576cf3
Merge branch 'serialize_sealed_classes' of https://github.com/iruizma…
iruizmar Apr 6, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,33 @@ data class Post(

```

<h4>Polymorphic serialization (sealed classes)</h4>

This sdk will handle polymorphic serialization automatically if you have a sealed class and its children marked as `Serializable`. It will include a `type` property that will be used to discriminate which child class is the serialized.

You can change this `type` property by using the `@FirebaseClassDiscrminator` annotation in the parent sealed class:

```kotlin
@Serializable
@FirebaseClassDiscriminator("class")
sealed class Parent {
@Serializable
@SerialName("child")
data class Child(
val property: Boolean
) : Parent
}
```

In combination with a `SerialName` specified for the child class, you have full control over the serialized data. In this case it will be:

```json
{
"class": "child",
"property": true
}
```

nbransby marked this conversation as resolved.
Show resolved Hide resolved
<h3><a href="https://kotlinlang.org/docs/reference/functions.html#default-arguments">Default arguments</a></h3>

To reduce boilerplate, default arguments are used in the places where the Firebase Android SDK employs the builder pattern:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@
package dev.gitlive.firebase

import kotlinx.serialization.encoding.CompositeDecoder
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PolymorphicKind
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.StructureKind

actual fun FirebaseDecoder.structureDecoder(descriptor: SerialDescriptor, decodeDouble: (value: Any?) -> Double?): CompositeDecoder = when(descriptor.kind as StructureKind) {
StructureKind.CLASS, StructureKind.OBJECT -> (value as Map<*, *>).let { map ->
actual fun FirebaseDecoder.structureDecoder(descriptor: SerialDescriptor, decodeDouble: (value: Any?) -> Double?): CompositeDecoder = when(descriptor.kind) {
StructureKind.CLASS, StructureKind.OBJECT, PolymorphicKind.SEALED -> (value as Map<*, *>).let { map ->
FirebaseClassDecoder(decodeDouble, map.size, { map.containsKey(it) }) { desc, index -> map[desc.getElementName(index)] }
}
StructureKind.LIST -> (value as List<*>).let {
Expand All @@ -20,4 +19,8 @@ actual fun FirebaseDecoder.structureDecoder(descriptor: SerialDescriptor, decode
StructureKind.MAP -> (value as Map<*, *>).entries.toList().let {
FirebaseCompositeDecoder(decodeDouble, it.size) { _, index -> it[index/2].run { if(index % 2 == 0) key else value } }
}
}
else -> TODO("The firebase-kotlin-sdk does not support $descriptor for serialization yet")
}

actual fun getPolymorphicType(value: Any?, discriminator: String): String =
(value as Map<*,*>)[discriminator] as String
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,23 @@

package dev.gitlive.firebase

import kotlinx.serialization.encoding.CompositeEncoder
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.StructureKind
import kotlin.collections.set

actual fun FirebaseEncoder.structureEncoder(descriptor: SerialDescriptor): CompositeEncoder = when(descriptor.kind as StructureKind) {
actual fun FirebaseEncoder.structureEncoder(descriptor: SerialDescriptor): FirebaseCompositeEncoder = when(descriptor.kind) {
StructureKind.LIST -> mutableListOf<Any?>()
.also { value = it }
.let { FirebaseCompositeEncoder(shouldEncodeElementDefault, positiveInfinity) { _, index, value -> it.add(index, value) } }
StructureKind.MAP -> mutableListOf<Any?>()
.let { FirebaseCompositeEncoder(shouldEncodeElementDefault, positiveInfinity, { value = it.chunked(2).associate { (k, v) -> k to v } }) { _, _, value -> it.add(value) } }
StructureKind.CLASS, StructureKind.OBJECT -> mutableMapOf<Any?, Any?>()
.also { value = it }
.let { FirebaseCompositeEncoder(shouldEncodeElementDefault, positiveInfinity) { _, index, value -> it[descriptor.getElementName(index)] = value } }
.let { FirebaseCompositeEncoder(shouldEncodeElementDefault, positiveInfinity,
setPolymorphicType = { discriminator, type ->
it[discriminator] = type
},
set = { _, index, value -> it[descriptor.getElementName(index)] = value }
) }
else -> TODO("The firebase-kotlin-sdk does not support $descriptor for serialization yet")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package dev.gitlive.firebase

import kotlinx.serialization.InheritableSerialInfo

@InheritableSerialInfo
@Target(AnnotationTarget.CLASS)
annotation class FirebaseClassDiscriminator(val discriminator: String)
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package dev.gitlive.firebase

import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.SerializationStrategy
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.findPolymorphicSerializer
import kotlinx.serialization.internal.AbstractPolymorphicSerializer

/*
* This code was inspired on polymorphic json serialization of kotlinx.serialization.
* See https://github.com/Kotlin/kotlinx.serialization/blob/master/formats/json/commonMain/src/kotlinx/serialization/json/internal/Polymorphic.kt
*/
@Suppress("UNCHECKED_CAST")
internal fun <T> FirebaseEncoder.encodePolymorphically(
serializer: SerializationStrategy<T>,
value: T,
ifPolymorphic: (String) -> Unit
) {
if (serializer !is AbstractPolymorphicSerializer<*>) {
serializer.serialize(this, value)
return
}
val casted = serializer as AbstractPolymorphicSerializer<Any>
val baseClassDiscriminator = serializer.descriptor.classDiscriminator()
val actualSerializer = casted.findPolymorphicSerializer(this, value as Any)
ifPolymorphic(baseClassDiscriminator)
actualSerializer.serialize(this, value)
}

@Suppress("UNCHECKED_CAST")
internal fun <T> FirebaseDecoder.decodeSerializableValuePolymorphic(
value: Any?,
decodeDouble: (value: Any?) -> Double?,
deserializer: DeserializationStrategy<T>,
): T {
if (deserializer !is AbstractPolymorphicSerializer<*>) {
return deserializer.deserialize(this)
}

val casted = deserializer as AbstractPolymorphicSerializer<Any>
val discriminator = deserializer.descriptor.classDiscriminator()
val type = getPolymorphicType(value, discriminator)
val actualDeserializer = casted.findPolymorphicSerializerOrNull(
structureDecoder(deserializer.descriptor, decodeDouble),
type
) as DeserializationStrategy<T>
return actualDeserializer.deserialize(this)
}

internal fun SerialDescriptor.classDiscriminator(): String {
// Plain loop is faster than allocation of Sequence or ArrayList
// We can rely on the fact that only one FirebaseClassDiscriminator is present —
// compiler plugin checked that.
for (annotation in annotations) {
if (annotation is FirebaseClassDiscriminator) return annotation.discriminator
}
return "type"
}

Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ fun <T> decode(strategy: DeserializationStrategy<T>, value: Any?, decodeDouble:
require(value != null || strategy.descriptor.isNullable) { "Value was null for non-nullable type ${strategy.descriptor.serialName}" }
return FirebaseDecoder(value, decodeDouble).decodeSerializableValue(strategy)
}

expect fun FirebaseDecoder.structureDecoder(descriptor: SerialDescriptor, decodeDouble: (value: Any?) -> Double?): CompositeDecoder
expect fun getPolymorphicType(value: Any?, discriminator: String): String

class FirebaseDecoder(internal val value: Any?, private val decodeDouble: (value: Any?) -> Double?) : Decoder {

Expand Down Expand Up @@ -59,8 +59,11 @@ class FirebaseDecoder(internal val value: Any?, private val decodeDouble: (value

override fun decodeNull() = decodeNull(value)

@ExperimentalSerializationApi
override fun decodeInline(inlineDescriptor: SerialDescriptor) = FirebaseDecoder(value, decodeDouble)

override fun <T> decodeSerializableValue(deserializer: DeserializationStrategy<T>): T {
return decodeSerializableValuePolymorphic(value, decodeDouble, deserializer)
}
}

class FirebaseClassDecoder(
Expand All @@ -80,7 +83,7 @@ class FirebaseClassDecoder(
?: DECODE_DONE
}

open class FirebaseCompositeDecoder constructor(
open class FirebaseCompositeDecoder(
private val decodeDouble: (value: Any?) -> Double?,
private val size: Int,
private val get: (descriptor: SerialDescriptor, index: Int) -> Any?
Expand Down Expand Up @@ -134,6 +137,7 @@ open class FirebaseCompositeDecoder constructor(
@ExperimentalSerializationApi
override fun decodeInlineElement(descriptor: SerialDescriptor, index: Int): Decoder =
FirebaseDecoder(get(descriptor, index), decodeDouble)

}

private fun decodeString(value: Any?) = value.toString()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,23 @@ inline fun <reified T> encode(value: T, shouldEncodeElementDefault: Boolean, pos
FirebaseEncoder(shouldEncodeElementDefault, positiveInfinity).apply { encodeSerializableValue(it.firebaseSerializer(), it) }.value
}

expect fun FirebaseEncoder.structureEncoder(descriptor: SerialDescriptor): CompositeEncoder
expect fun FirebaseEncoder.structureEncoder(descriptor: SerialDescriptor): FirebaseCompositeEncoder

class FirebaseEncoder(internal val shouldEncodeElementDefault: Boolean, positiveInfinity: Any) : TimestampEncoder(positiveInfinity), Encoder {

var value: Any? = null

override val serializersModule = EmptySerializersModule
override fun beginStructure(descriptor: SerialDescriptor) = structureEncoder(descriptor)
private var polymorphicDiscriminator: String? = null

override fun beginStructure(descriptor: SerialDescriptor): CompositeEncoder {
val encoder = structureEncoder(descriptor)
if (polymorphicDiscriminator != null) {
encoder.encodePolymorphicClassDiscriminator(polymorphicDiscriminator!!, descriptor.serialName)
polymorphicDiscriminator = null
}
return encoder
}

override fun encodeBoolean(value: Boolean) {
this.value = value
Expand Down Expand Up @@ -73,9 +82,14 @@ class FirebaseEncoder(internal val shouldEncodeElementDefault: Boolean, positive
this.value = value
}

@ExperimentalSerializationApi
override fun encodeInline(inlineDescriptor: SerialDescriptor): Encoder =
FirebaseEncoder(shouldEncodeElementDefault, positiveInfinity)

override fun <T> encodeSerializableValue(serializer: SerializationStrategy<T>, value: T) {
encodePolymorphically(serializer, value) {
polymorphicDiscriminator = it
}
}
}

abstract class TimestampEncoder(internal val positiveInfinity: Any) {
Expand All @@ -89,7 +103,8 @@ open class FirebaseCompositeEncoder constructor(
private val shouldEncodeElementDefault: Boolean,
positiveInfinity: Any,
private val end: () -> Unit = {},
private val set: (descriptor: SerialDescriptor, index: Int, value: Any?) -> Unit
private val setPolymorphicType: (String, String) -> Unit = { _, _ -> },
private val set: (descriptor: SerialDescriptor, index: Int, value: Any?) -> Unit,
): TimestampEncoder(positiveInfinity), CompositeEncoder {

override val serializersModule = EmptySerializersModule
Expand Down Expand Up @@ -153,6 +168,9 @@ open class FirebaseCompositeEncoder constructor(
@ExperimentalSerializationApi
override fun encodeInlineElement(descriptor: SerialDescriptor, index: Int): Encoder =
FirebaseEncoder(shouldEncodeElementDefault, positiveInfinity)
}

fun encodePolymorphicClassDiscriminator(discriminator: String, type: String) {
setPolymorphicType(discriminator, type)
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

package dev.gitlive.firebase

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlin.test.Test
Expand All @@ -17,6 +18,13 @@ expect fun nativeAssertEquals(expected: Any?, actual: Any?): Unit
@Serializable
data class TestData(val map: Map<String, String>, val bool: Boolean = false, val nullableBool: Boolean? = null)

@Serializable
sealed class TestSealed {
@Serializable
@SerialName("child")
data class ChildClass(val map: Map<String, String>, val bool: Boolean = false): TestSealed()
}

class EncodersTest {
@Test
fun encodeMap() {
Expand All @@ -37,6 +45,12 @@ class EncodersTest {
nativeAssertEquals(nativeMapOf("map" to nativeMapOf("key" to "value"), "bool" to true, "nullableBool" to true), encoded)
}

@Test
fun encodeSealedClass() {
val encoded = encode<TestSealed>(TestSealed.serializer(), TestSealed.ChildClass(mapOf("key" to "value"), true), shouldEncodeElementDefault = true)
nativeAssertEquals(nativeMapOf("type" to "child", "map" to nativeMapOf("key" to "value"), "bool" to true), encoded)
}

@Test
fun decodeObject() {
val decoded = decode<TestData>(TestData.serializer(), nativeMapOf("map" to nativeMapOf("key" to "value")))
Expand All @@ -54,4 +68,10 @@ class EncodersTest {
val decoded = decode(TestData.serializer(), nativeMapOf("map" to mapOf("key" to "value"), "nullableBool" to null))
assertNull(decoded.nullableBool)
}

@Test
fun decodeSealedClass() {
val decoded = decode(TestSealed.serializer(), nativeMapOf("type" to "child", "map" to nativeMapOf("key" to "value"), "bool" to true))
assertEquals(TestSealed.ChildClass(mapOf("key" to "value"), true), decoded)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@
package dev.gitlive.firebase

import kotlinx.serialization.encoding.CompositeDecoder
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PolymorphicKind
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.StructureKind

actual fun FirebaseDecoder.structureDecoder(descriptor: SerialDescriptor, decodeDouble: (value: Any?) -> Double?): CompositeDecoder = when(descriptor.kind as StructureKind) {
StructureKind.CLASS, StructureKind.OBJECT -> (value as Map<*, *>).let { map ->
actual fun FirebaseDecoder.structureDecoder(descriptor: SerialDescriptor, decodeDouble: (value: Any?) -> Double?): CompositeDecoder = when(descriptor.kind) {
StructureKind.CLASS, StructureKind.OBJECT, PolymorphicKind.SEALED -> (value as Map<*, *>).let { map ->
FirebaseClassDecoder(decodeDouble, map.size, { map.containsKey(it) }) { desc, index -> map[desc.getElementName(index)] }
}
StructureKind.LIST -> (value as List<*>).let {
Expand All @@ -20,4 +19,8 @@ actual fun FirebaseDecoder.structureDecoder(descriptor: SerialDescriptor, decode
StructureKind.MAP -> (value as Map<*, *>).entries.toList().let {
FirebaseCompositeDecoder(decodeDouble, it.size) { _, index -> it[index/2].run { if(index % 2 == 0) key else value } }
}
}
else -> TODO("The firebase-kotlin-sdk does not support $descriptor for serialization yet")
}

actual fun getPolymorphicType(value: Any?, discriminator: String): String =
(value as Map<*,*>)[discriminator] as String
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,25 @@

package dev.gitlive.firebase

import kotlinx.serialization.descriptors.PolymorphicKind
import kotlinx.serialization.encoding.CompositeEncoder
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.StructureKind
import kotlin.collections.set

actual fun FirebaseEncoder.structureEncoder(descriptor: SerialDescriptor): CompositeEncoder = when(descriptor.kind as StructureKind) {
actual fun FirebaseEncoder.structureEncoder(descriptor: SerialDescriptor): FirebaseCompositeEncoder = when(descriptor.kind) {
StructureKind.LIST -> mutableListOf<Any?>()
.also { value = it }
.let { FirebaseCompositeEncoder(shouldEncodeElementDefault, positiveInfinity) { _, index, value -> it.add(index, value) } }
StructureKind.MAP -> mutableListOf<Any?>()
.let { FirebaseCompositeEncoder(shouldEncodeElementDefault, positiveInfinity, { value = it.chunked(2).associate { (k, v) -> k to v } }) { _, _, value -> it.add(value) } }
StructureKind.CLASS, StructureKind.OBJECT -> mutableMapOf<Any?, Any?>()
StructureKind.CLASS, StructureKind.OBJECT, PolymorphicKind.SEALED -> mutableMapOf<Any?, Any?>()
.also { value = it }
.let { FirebaseCompositeEncoder(shouldEncodeElementDefault, positiveInfinity) { _, index, value -> it[descriptor.getElementName(index)] = value } }
.let { FirebaseCompositeEncoder(shouldEncodeElementDefault, positiveInfinity,
setPolymorphicType = { discriminator, type ->
it[discriminator] = type
},
set = { _, index, value -> it[descriptor.getElementName(index)] = value }
) }
else -> TODO("The firebase-kotlin-sdk does not support $descriptor for serialization yet")
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@
package dev.gitlive.firebase

import kotlinx.serialization.encoding.CompositeDecoder
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PolymorphicKind
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.StructureKind
import kotlin.js.Json

@Suppress("UNCHECKED_CAST_TO_EXTERNAL_INTERFACE")
actual fun FirebaseDecoder.structureDecoder(descriptor: SerialDescriptor, decodeDouble: (value: Any?) -> Double?): CompositeDecoder = when(descriptor.kind as StructureKind) {
StructureKind.CLASS, StructureKind.OBJECT -> (value as Json).let { json ->
actual fun FirebaseDecoder.structureDecoder(descriptor: SerialDescriptor, decodeDouble: (value: Any?) -> Double?): CompositeDecoder = when(descriptor.kind) {
StructureKind.CLASS, StructureKind.OBJECT, PolymorphicKind.SEALED -> (value as Json).let { json ->
FirebaseClassDecoder(decodeDouble, js("Object").keys(value).length as Int, { json[it] != undefined }) {
desc, index -> json[desc.getElementName(index)]
}
Expand All @@ -24,4 +23,9 @@ actual fun FirebaseDecoder.structureDecoder(descriptor: SerialDescriptor, decode
StructureKind.MAP -> (js("Object").entries(value) as Array<Array<Any>>).let {
FirebaseCompositeDecoder(decodeDouble, it.size) { _, index -> it[index/2].run { if(index % 2 == 0) get(0) else get(1) } }
}
else -> TODO("The firebase-kotlin-sdk does not support $descriptor for serialization yet")
}

@Suppress("UNCHECKED_CAST_TO_EXTERNAL_INTERFACE")
actual fun getPolymorphicType(value: Any?, discriminator: String): String =
(value as Json)[discriminator] as String
Loading