diff --git a/README.md b/README.md index 0e146d3..5643091 100644 --- a/README.md +++ b/README.md @@ -229,3 +229,26 @@ sealed class Social { println(item0) // {name=S(value=Codanbaru 0), social=M(value={email=S(value=demo@codanbaru.com), type=S(value=com.codanbaru.app.Social.Email)})} println(item1) // {name=S(value=Codanbaru 0), social=M(value={username=S(value=@codanbaru), type=S(value=com.codanbaru.app.Social.Instagram)})} ``` + +## Working with Maps + +You can configure map types to be serialized in a typical key/value map structure instead of the default numerically indexed structure. + +Example: + +```kotlin +@Serializable +data class User( + val badgeCounts: Map +) + +val user = User(badges = mapOf("badge1" to 5)) + +Dynamap { + indexMapsByKeys = false +}.encodeToItem(user) // {badges=M(value={0=S(value=badge1), 1=N(value=5)})} + +Dynamap { + indexMapsByKeys = true +}.encodeToItem(user) // {badges=M(value={badge1=N(value=5)})} +``` diff --git a/dynamap-lib/src/main/kotlin/com/codanbaru/serialization/DynamapBuilder.kt b/dynamap-lib/src/main/kotlin/com/codanbaru/serialization/DynamapBuilder.kt index e5ae44a..4519769 100644 --- a/dynamap-lib/src/main/kotlin/com/codanbaru/serialization/DynamapBuilder.kt +++ b/dynamap-lib/src/main/kotlin/com/codanbaru/serialization/DynamapBuilder.kt @@ -11,11 +11,14 @@ public class DynamapBuilder internal constructor(dynamap: Dynamap) { public var booleanLiteral: DynamapConfiguration.BooleanLiteral = dynamap.configuration.booleanLiteral + public var indexMapsByKeys: Boolean = dynamap.configuration.indexMapsByKeys + fun build(): DynamapConfiguration { return DynamapConfiguration( classDiscriminator = this@DynamapBuilder.classDiscriminator, evaluateUndefinedAttributesAsNullAttribute = this@DynamapBuilder.evaluateUndefinedAttributesAsNullAttribute, - booleanLiteral = this@DynamapBuilder.booleanLiteral + booleanLiteral = this@DynamapBuilder.booleanLiteral, + indexMapsByKeys = this@DynamapBuilder.indexMapsByKeys ) } } \ No newline at end of file diff --git a/dynamap-lib/src/main/kotlin/com/codanbaru/serialization/DynamapConfiguration.kt b/dynamap-lib/src/main/kotlin/com/codanbaru/serialization/DynamapConfiguration.kt index ebc3bfb..fff2fdb 100644 --- a/dynamap-lib/src/main/kotlin/com/codanbaru/serialization/DynamapConfiguration.kt +++ b/dynamap-lib/src/main/kotlin/com/codanbaru/serialization/DynamapConfiguration.kt @@ -5,7 +5,14 @@ public data class DynamapConfiguration( public val evaluateUndefinedAttributesAsNullAttribute: Boolean = true, - public val booleanLiteral: BooleanLiteral = BooleanLiteral("TRUE", "FALSE", false) + public val booleanLiteral: BooleanLiteral = BooleanLiteral("TRUE", "FALSE", false), + + /** + * Map types will be serialized to a structure like: mapOf("a" to 1, "b" to 2) + * false => { "0": "a", "1": 1, "2": "b", "3": 2) + * true => { "a": 1, "b": 2 } + */ + public val indexMapsByKeys: Boolean = false, ) { public data class BooleanLiteral( diff --git a/dynamap-lib/src/main/kotlin/com/codanbaru/serialization/DynamoMapCompositeDecoder.kt b/dynamap-lib/src/main/kotlin/com/codanbaru/serialization/DynamoMapCompositeDecoder.kt index a1ed928..e028b14 100644 --- a/dynamap-lib/src/main/kotlin/com/codanbaru/serialization/DynamoMapCompositeDecoder.kt +++ b/dynamap-lib/src/main/kotlin/com/codanbaru/serialization/DynamoMapCompositeDecoder.kt @@ -4,6 +4,7 @@ import aws.sdk.kotlin.services.dynamodb.model.AttributeValue import com.codanbaru.serialization.extension.subproperty import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.StructureKind import kotlinx.serialization.encoding.CompositeDecoder import kotlinx.serialization.modules.SerializersModule @@ -16,6 +17,8 @@ internal class DynamoMapCompositeDecoder( override val configuration: DynamapConfiguration, override val serializersModule: SerializersModule ): DynamoCompositeDecoder(property, configuration, serializersModule) { + private val keys by lazy { `object`.keys.toList() } + override fun decodeElement(descriptor: SerialDescriptor, index: Int, builder: (List, SerialDescriptor, String, AttributeValue) -> T): T { val elementAnnotations = annotationsAtIndex(descriptor, index) val elementDescriptor = descriptorAtIndex(descriptor, index) @@ -27,7 +30,11 @@ internal class DynamoMapCompositeDecoder( private var currentIndex = 0 override fun decodeElementIndex(descriptor: SerialDescriptor): Int { - if (currentIndex >= descriptor.elementsCount) return CompositeDecoder.DECODE_DONE + when (descriptor.kind) { + is StructureKind.CLASS -> if (currentIndex >= descriptor.elementsCount) return CompositeDecoder.DECODE_DONE + is StructureKind.MAP -> if (currentIndexIsInvalidForMapKind()) return CompositeDecoder.DECODE_DONE + else -> Unit + } val currentIndex = this.currentIndex this.currentIndex += 1 @@ -42,6 +49,16 @@ internal class DynamoMapCompositeDecoder( } } + /** + * For maps, kotlinx serialization expects to call decodeElement 2 times for every *entry* in the final map which is + * why we need to double the map size when indexing by keys. + */ + private fun currentIndexIsInvalidForMapKind() = if (configuration.indexMapsByKeys) { + currentIndex >= `object`.size * 2 + } else { + currentIndex >= `object`.size + } + private fun annotationsAtIndex(descriptor: SerialDescriptor, index: Int): List = descriptor.getElementAnnotations(index) @@ -54,6 +71,14 @@ internal class DynamoMapCompositeDecoder( private fun elementAtIndex(descriptor: SerialDescriptor, index: Int): AttributeValue { val propertyName = propertyAtIndex(descriptor, index) + if (descriptor.kind is StructureKind.MAP && configuration.indexMapsByKeys) { + val key = keys[index / 2] + return when (index % 2 == 0) { + true -> AttributeValue.S(key) + false -> `object`.getValue(key) + } + } + var element = `object`[propertyName] if (element == null && configuration.evaluateUndefinedAttributesAsNullAttribute) { if (!descriptor.isElementOptional(index)) { diff --git a/dynamap-lib/src/main/kotlin/com/codanbaru/serialization/DynamoMapCompositeEncoder.kt b/dynamap-lib/src/main/kotlin/com/codanbaru/serialization/DynamoMapCompositeEncoder.kt index eae256a..6df15a3 100644 --- a/dynamap-lib/src/main/kotlin/com/codanbaru/serialization/DynamoMapCompositeEncoder.kt +++ b/dynamap-lib/src/main/kotlin/com/codanbaru/serialization/DynamoMapCompositeEncoder.kt @@ -1,9 +1,11 @@ package com.codanbaru.serialization import aws.sdk.kotlin.services.dynamodb.model.AttributeValue +import com.codanbaru.serialization.extension.subproperty import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.StructureKind import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.modules.SerializersModule @@ -18,12 +20,34 @@ internal class DynamoMapCompositeEncoder( ): DynamoCompositeEncoder(property, configuration, serializersModule) { private var `object`: MutableMap = mutableMapOf() + // implementation borrowed from JsonEncoder - https://github.com/Kotlin/kotlinx.serialization/blob/master/formats/json/commonMain/src/kotlinx/serialization/json/internal/TreeJsonEncoder.kt#L234 + private lateinit var key: String + private var isKey: Boolean = true + override fun encodeElement(descriptor: SerialDescriptor, index: Int, value: T, builder: (List, SerialDescriptor, String, T, (AttributeValue) -> Unit) -> Unit) { val elementAnnotations = annotationsAtIndex(descriptor, index) val elementDescriptor = descriptorAtIndex(descriptor, index) val elementName = propertyAtIndex(descriptor, index) - builder(elementAnnotations, elementDescriptor, elementName, value) { `object`[elementName] = it /* CHECK: Should we raise exception if encoder is already finished? */ } + builder(elementAnnotations, elementDescriptor, elementName, value) { + if (descriptor.kind is StructureKind.MAP && configuration.indexMapsByKeys) { + when (isKey) { + true -> when (it) { + is AttributeValue.S -> { + key = it.asS() + isKey = false + } + else -> error("dynamo maps must have string-able keys: property=${property.subproperty(elementName)}, value=$value") + } + false -> { + `object`[key] = it /* CHECK: Should we raise exception if encoder is already finished? */ + isKey = true + } + } + } else { + `object`[elementName] = it /* CHECK: Should we raise exception if encoder is already finished? */ + } + } } override fun encodeInlineElement(descriptor: SerialDescriptor, index: Int, builder: (List, SerialDescriptor, String, (AttributeValue) -> Unit) -> Encoder): Encoder { diff --git a/dynamap-lib/src/test/kotlin/com/codanbaru/serialization/DynamapTest.kt b/dynamap-lib/src/test/kotlin/com/codanbaru/serialization/DynamapTest.kt index 287b3b7..aaaca8c 100644 --- a/dynamap-lib/src/test/kotlin/com/codanbaru/serialization/DynamapTest.kt +++ b/dynamap-lib/src/test/kotlin/com/codanbaru/serialization/DynamapTest.kt @@ -1,14 +1,18 @@ package com.codanbaru.serialization import aws.sdk.kotlin.services.dynamodb.model.AttributeValue +import com.codanbaru.serialization.format.decodeFromAttribute import com.codanbaru.serialization.format.decodeFromItem +import com.codanbaru.serialization.format.encodeToAttribute import com.codanbaru.serialization.format.encodeToItem +import kotlinx.serialization.Serializable import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertThrows import kotlin.test.Test import kotlin.test.assertEquals class DynamapTest { - val dynamap = Dynamap { + var dynamap = Dynamap { classDiscriminator = "_type" } @@ -104,6 +108,105 @@ class DynamapTest { ) } + @Test + fun `cannot encode or decode objects`() { + assertThrows { + dynamap.encodeToItem(Fixtures.SingletonObject) + } + } + + @Test + fun `supports lists - non empty`() { + assertCodec( + Fixtures.ContainsList(listOf("a")), + mapOf("list" to AttributeValue.L(listOf(AttributeValue.S("a")))) + ) + } + + @Test + fun `supports lists - empty`() { + assertCodec( + Fixtures.ContainsList(emptyList()), + mapOf("list" to AttributeValue.L(emptyList())) + ) + } + + @Test + fun `supports maps - non empty - index by keys = false`() { + dynamap = Dynamap { + indexMapsByKeys = false + } + + assertCodec( + Fixtures.ContainsMap(mapOf("a" to 1, "b" to 2, "c" to 3)), + mapOf("map" to AttributeValue.M(mapOf( + "0" to AttributeValue.S("a"), + "1" to AttributeValue.N("1"), + "2" to AttributeValue.S("b"), + "3" to AttributeValue.N("2"), + "4" to AttributeValue.S("c"), + "5" to AttributeValue.N("3"), + ))) + ) + } + + @Test + fun `can encode maps of enums`() { + dynamap = Dynamap { + indexMapsByKeys = true + } + + val expectedValue = mapOf( + Fixtures.TestEnum.TestA to 1 + ) + val expectedAttribute = AttributeValue.M(mapOf( + "TestA" to AttributeValue.N("1") + )) + + assertAll( + { assertEquals(expectedAttribute, dynamap.encodeToAttribute(expectedValue)) }, + { assertEquals(expectedValue, dynamap.decodeFromAttribute(expectedAttribute)) }, + { assertEquals(expectedValue, dynamap.decodeFromAttribute(dynamap.encodeToAttribute(expectedValue))) }, + ) + } + + @Test + fun `supports maps - non empty - index by keys = true`() { + dynamap = Dynamap { + indexMapsByKeys = true + } + + assertCodec( + Fixtures.ContainsMap(mapOf("a" to 1, "b" to 2, "c" to 3)), + mapOf("map" to AttributeValue.M(mapOf( + "a" to AttributeValue.N("1"), + "b" to AttributeValue.N("2"), + "c" to AttributeValue.N("3"), + ))) + ) + } + + @Test + fun `supports maps - empty`() { + assertCodec( + Fixtures.ContainsMap(emptyMap()), + mapOf("map" to AttributeValue.M(emptyMap())) + ) + } + + @Test + fun `supports maps - nested types`() { + dynamap = Dynamap { indexMapsByKeys = true } + assertCodec( + Fixtures.ContainsComplexMap(mapOf("a" to Fixtures.ContainsComplexMap.Nested(1))), + mapOf("map" to AttributeValue.M(mapOf( + "a" to AttributeValue.M(mapOf( + "a" to AttributeValue.N("1") + )) + ))) + ) + } + @Test fun `polymorphic`() { assertAll( @@ -122,10 +225,11 @@ class DynamapTest { } inline fun assertCodec(expectedObj: T, expectedItem: Map) { + val encodedItem = dynamap.encodeToItem(expectedObj) assertAll( - { assertEquals(expectedItem, dynamap.encodeToItem(expectedObj), "encoding to tiem") }, + { assertEquals(expectedItem, encodedItem, "encoding to item") }, { assertDecode(expectedObj, expectedItem) }, - { assertEquals(expectedObj, dynamap.decodeFromItem(dynamap.encodeToItem(expectedObj))) }, + { assertEquals(expectedObj, dynamap.decodeFromItem(encodedItem)) }, ) } diff --git a/dynamap-lib/src/test/kotlin/com/codanbaru/serialization/Fixtures.kt b/dynamap-lib/src/test/kotlin/com/codanbaru/serialization/Fixtures.kt index dacb5ad..17680ef 100644 --- a/dynamap-lib/src/test/kotlin/com/codanbaru/serialization/Fixtures.kt +++ b/dynamap-lib/src/test/kotlin/com/codanbaru/serialization/Fixtures.kt @@ -16,6 +16,29 @@ object Fixtures { val double: Double, ) + @Serializable + data class ContainsList( + val list: List + ) + + @Serializable + data class ContainsMap( + val map: Map + ) + + @Serializable + data class ContainsComplexMap( + val map: Map + ) { + @Serializable + data class Nested(val a: Int) + } + + @Serializable + data object SingletonObject { + val a: Int = 0 + } + @Serializable data class Optionals( val a: Int, @@ -23,6 +46,12 @@ object Fixtures { val c: Int = 2 ) + enum class TestEnum { + TestA, + TestB, + TestC + } + @Serializable data class Polymorphic(val type: Type) {