Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Int>
)

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)})}
```
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, *> 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <T> decodeElement(descriptor: SerialDescriptor, index: Int, builder: (List<Annotation>, SerialDescriptor, String, AttributeValue) -> T): T {
val elementAnnotations = annotationsAtIndex(descriptor, index)
val elementDescriptor = descriptorAtIndex(descriptor, index)
Expand All @@ -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
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this logic only works when kind is a CLASS. if a map type, elements count is always 2 which refers to the types.

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
Expand All @@ -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<Annotation> =
descriptor.getElementAnnotations(index)

Expand All @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -18,12 +20,34 @@ internal class DynamoMapCompositeEncoder(
): DynamoCompositeEncoder(property, configuration, serializersModule) {
private var `object`: MutableMap<String, AttributeValue> = 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 <T> encodeElement(descriptor: SerialDescriptor, index: Int, value: T, builder: (List<Annotation>, 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<Annotation>, SerialDescriptor, String, (AttributeValue) -> Unit) -> Encoder): Encoder {
Expand Down
110 changes: 107 additions & 3 deletions dynamap-lib/src/test/kotlin/com/codanbaru/serialization/DynamapTest.kt
Original file line number Diff line number Diff line change
@@ -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"
}

Expand Down Expand Up @@ -104,6 +108,105 @@ class DynamapTest {
)
}

@Test
fun `cannot encode or decode objects`() {
assertThrows<DynamapSerializationException.InvalidKind> {
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(
Expand All @@ -122,10 +225,11 @@ class DynamapTest {
}

inline fun <reified T>assertCodec(expectedObj: T, expectedItem: Map<String, AttributeValue>) {
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)) },
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,42 @@ object Fixtures {
val double: Double,
)

@Serializable
data class ContainsList(
val list: List<String>
)

@Serializable
data class ContainsMap(
val map: Map<String, Int>
)

@Serializable
data class ContainsComplexMap(
val map: Map<String, Nested>
) {
@Serializable
data class Nested(val a: Int)
}

@Serializable
data object SingletonObject {
val a: Int = 0
}

@Serializable
data class Optionals(
val a: Int,
val b: Int = 1,
val c: Int = 2
)

enum class TestEnum {
TestA,
TestB,
TestC
}

@Serializable
data class Polymorphic(val type: Type) {

Expand Down