Skip to content

jaitl/kDynamoMapper

Repository files navigation

kDynamoMapper

build codecov Codacy Badge

release Maven Central License

Lightweight AWS DynamoDB mapper for Kotlin written in pure Kotlin.

kDynamoMapper supports only AWS SDK for Java 2.x.

kDynamoMapper maps a data class to AttributeValue and vice versa. kDynamoMapper doesn't wrap DynamoDbClient. You have to use original DynamoDbClient from AWS SDK for Java 2.0 to work with DynamoDB.

Installation

Maven

<dependency>
  <groupId>pro.jaitl</groupId>
  <artifactId>k-dynamo-mapper</artifactId>
  <version>version</version>
</dependency>

Gradle Groovy

implementation 'pro.jaitl:k-dynamo-mapper:<version>'

Gradle Kotlin

implementation("pro.jaitl:k-dynamo-mapper:<version>")

Usage

kDynamoMapper supports only data classes. When a data class contains another data class as a property it will be mapped as well.

Mapper creating

val mapper: KDynamoMapper = Mapper()

Data classes for examples

data class NestedObject(val dataDouble: Double, val dataInstant: Instant)
data class MyData(val id: String, val dataInt: Int, val nested: NestedObject)
data class MyKey(val id: String)

Writing

val data = MyData("1", 1234, NestedObject(333.33, Instant.now()))

val dynamoData = mapper.writeObject(data)

val putRequest = PutItemRequest.builder()
    .tableName(table.tableName)
    .item(dynamoData)
    .build()

dynamoDbClient.putItem(putRequest)

Reading

val keyValue = mapper.writeObject(MyKey(data.id))

val getRequest = GetItemRequest.builder()
    .key(keyValue)
    .tableName(table.tableName)
    .build()

val result = dynamoDbClient.getItem(getRequest)

val data = mapper.readObject<MyData>(result.item())
// or val data = mapper.readObject(result.item(), MyData::class)

Updating a value

val itemKey = mapper.writeObject(MyKey("1"))

val updatedValues = mapOf(
    "dataInt" to updateAttribute(
        attribute = mapper.writeValue(4321),
        action = AttributeAction.PUT
    )
)

val updateRequest = UpdateItemRequest.builder()
    .tableName(table.tableName)
    .key(itemKey)
    .attributeUpdates(updatedValues)
    .build()

dynamoDbClient.updateItem(updateRequest)

Updating a nested object

val itemKey = mapper.writeObject(MyKey("1"))
val newNested = NestedObject(4321.33, Instant.now().plusSeconds(1000))

val updatedValues = mapOf(
    "nested" to updateAttribute(
        attribute = mapper.writeValue(newNested),
        action = AttributeAction.PUT
    )
)

val updateRequest = UpdateItemRequest.builder()
    .tableName(table.tableName)
    .key(itemKey)
    .attributeUpdates(updatedValues)
    .build()

dynamoDbClient.updateItem(updateRequest)

You can run and play with the examples in integration tests.

ADT support

ADT are determined by inheritance from a sealed interface/class. Each ADT has to contain the adt_class_name field with the original class. During write, the adt_class_name field will be created automatically. To read an ADT it has to contain the adt_class_name field otherwise the RequiredFieldNotFoundException exception will be thrown.

ADT data classes

sealed class Adt {
    data class AdtOne(val int: Int, val string: String) : Adt()
    data class AdtTwo(val long: Long, val instant: Instant, val double: Double) : Adt()
}

data class MyKey(val id: String)
data class MyAdtData(val id: String, val adt: Adt)

Writing ADT

val data = MyAdtData("1", Adt.AdtOne(1234, "one one"))

val dynamoData = mapper.writeObject(data)

val putRequest = PutItemRequest.builder()
    .tableName(table.tableName)
    .item(dynamoData)
    .build()

dynamoDbClient.putItem(putRequest)

Updating ADT

val itemKey = mapper.writeObject(MyKey("1"))
val updatedAdt = Adt.AdtTwo(4321L, Instant.now(), 4444.0)

val updatedValues = mapOf(
    "adt" to updateAttribute(
        attribute = mapper.writeValue(updatedAdt),
        action = AttributeAction.PUT
    )
)

val updateRequest = UpdateItemRequest.builder()
    .tableName(table.tableName)
    .key(itemKey)
    .attributeUpdates(updatedValues)
    .build()

dynamoDbClient.updateItem(updateRequest)

Reading ADT

val keyValue = mapper.writeObject(MyKey("1"))

val getRequest = GetItemRequest.builder()
    .key(keyValue)
    .tableName(table.tableName)
    .build()

val result = dynamoDbClient.getItem(getRequest)

val updatedItem = mapper.readObject<MyAdtData>(result.item())
// or val updatedItem = mapper.readObject(result.item(), MyAdtData::class)

You can run and play with the examples in integration tests.

Set up a custom converter

Custom types

class SimpleDataType(val instant: Instant)
class ComplexDataType(val string: String, val int: Int, val simpleDataType: SimpleDataType)

Data classes with custom types

data class MyDataClass(
    val id: String,
    val simpleDataType: SimpleDataType,
    val complexDataType: ComplexDataType
)
data class MyDataKey(val id: String)

Converter for a simple custom type

This converter returns the custom data type as value.

class SimpleDataTypeConverter : TypeConverter<SimpleDataType> {
    override fun read(
        reader: KDynamoMapperReader,
        attr: AttributeValue,
        kType: KType
    ): SimpleDataType {
        return SimpleDataType(
            instant = reader.readValue(attr)
        )
    }

    override fun write(writer: KDynamoMapperWriter, value: Any, kType: KType): AttributeValue {
        val myData = value as SimpleDataType
        return writer.writeValue(myData.instant)
    }

    override fun type(): KClass<SimpleDataType> = SimpleDataType::class
}

Converter for a complex data type

This converter returns the custom data type as map.

class ComplexDataTypeConverter : TypeConverter<ComplexDataType> {
    override fun read(
        reader: KDynamoMapperReader,
        attr: AttributeValue,
        kType: KType
    ): ComplexDataType {
        val attrMap = attr.m()
        return ComplexDataType(
            string = reader.readValue(attrMap["string"]!!),
            int = reader.readValue(attrMap["int"]!!),
            simpleDataType = reader.readValue(attrMap["simpleDataType"]!!)
        )
    }

    override fun write(writer: KDynamoMapperWriter, value: Any, kType: KType): AttributeValue {
        val myData = value as ComplexDataType
        return mapAttribute(
            mapOf(
                "string" to writer.writeValue(myData.string),
                "int" to writer.writeValue(myData.int),
                "simpleDataType" to writer.writeValue(myData.simpleDataType),
            )
        )
    }

    override fun type(): KClass<ComplexDataType> = ComplexDataType::class
}

Creating the Mapper with custom converters

Configure custom converters then union them with default converters.

val customConvertersMap = listOf(SimpleDataTypeConverter(), ComplexDataTypeConverter())
    .associateBy { it.type() }
val registry = ConverterRegistry(DEFAULT_CONVERTERS + customConvertersMap)

val mapper = Mapper(registry)

Writing a data class with custom types

val data = MyDataClass(
    id = "1",
    simpleDataType = SimpleDataType(Instant.now()),
    complexDataType = ComplexDataType("test", 1234, SimpleDataType(Instant.now().plusSeconds(1000)))
)

val dynamoData = mapper.writeObject(data)

val putRequest = PutItemRequest.builder()
    .tableName(table.tableName)
    .item(dynamoData)
    .build()

dynamoDbClient.putItem(putRequest)

Reading a data class with custom types

val keyValue = mapper.writeObject(MyDataKey(data.id))

val getRequest = GetItemRequest.builder()
    .key(keyValue)
    .tableName(table.tableName)
    .build()

val result = dynamoDbClient.getItem(getRequest)

val actualData = mapper.readObject<MyDataClass>(result.item())
// or val actualData = mapper.readObject(result.item(), MyDataClass::class)

You can run and play with the examples in integration tests.

Notice

Please! If you have written a converter for a common data type that will be useful to other users, contribute it back to the project.

Contribution

  1. There are several opened issues. When you want to resolve an opened issue don't forget to write about it in the issue.
  2. If there isn't a needed converter for you feel free to open a new issue then implement and contribute the converter.