/KMapper

Object to Object mapper Libraly for Kotlin.

Primary LanguageKotlinApache License 2.0Apache-2.0

License CircleCI codecov


日本語版


KMapper

KMapper is a object to object mapper library for Kotlin, which provides the following features.

  • Bean mapping with Objects, Map, and Pair as sources
  • Flexible and safe mapping based on function calls with reflection.
  • Richer features and thus more flexible and labor-saving mapping.

A brief benchmark result is posted in the following repository.

Demo code

Here is a comparison between writing the mapping code by manually and using KMapper.

If you write it manually, the more arguments you have, the more complicated the description will be.
However, by using KMapper, you can perform mapping without writing much code.

Also, no external configuration file is required.

// If you write manually.
val dst = Dst(
    param1 = src.param1,
    param2 = src.param2,
    param3 = src.param3,
    param4 = src.param4,
    param5 = src.param5,
    ...
)

// If you use KMapper
val dst = KMapper(::Dst).map(src)

You can specify not only one source, but also multiple objects, Pair, Map, etc.

val dst = KMapper(::Dst).map(
    "param1" to "value of param1",
    mapOf("param2" to 1, "param3" to 2L),
    src1,
    src2
)

Installation

KMapper is published on JitPack.
You can use this library on maven, gradle and any other build tools.
Please see here for the introduction method.

Example on maven

1. add repository reference for JitPack

<repositories>
    <repository>
        <id>jitpack.io</id>
        <url>https://jitpack.io</url>
    </repository>
</repositories>

2. add dependency

<dependency>
    <groupId>com.github.ProjectMapK</groupId>
    <artifactId>KMapper</artifactId>
    <version>Tag</version>
</dependency>

Principle of operation

The behavior of KMapper is as follows.

  1. Get the KFunction to be called.
  2. Analyze the KFunction and determine what arguments are needed and how to deserialize them.
  3. Get the value for each argument from inputs and deserialize it. and call the KFunction.

KMapper performs the mapping by calling a function, so the result is a Subject to the constraints on the argument and nullability.
That is, there is no runtime error due to breaking the null safety of Kotlin(The null safety on type arguments may be broken due to problems on the Kotlin side).

Also, it supports the default arguments which are peculiar to Kotlin.

Types of mapper classes

The project offers three types of mapper classes.

  • KMapper
  • PlainKMapper
  • BoundKMapper

Here is a summary of the features and advantages of each.
Also, the common features are explained using KMapper as an example.

KMapper

The KMapper is a basic mapper class for this project.
It is suitable for using the same instance of the class, since it is cached internally to speed up the mapping process.

PlainKMapper

PlainKMapper is a mapper class from KMapper without caching.
Although the performance is not as good as KMapper in case of multiple mappings, it is suitable for use as a disposable mapper because there is no overhead of cache processing.

BoundKMapper

BoundKMapper is a mapping class for the case where only one source class is available.
It is faster than KMapper.

Initialization

KMapper can be initialized from method reference(KFunction) to be called or the KClass to be mapped.

The following is a summary of each initialization.
However, some of the initialization of BoundKMapper are shown as examples simplified by a dummy constructor.

Initialize from method reference(KFunction)

When the primary constructor is the target of a call, you can initialize it as follows.

data class Dst(
    foo: String,
    bar: String,
    baz: Int?,

    ...

)

// Get constructor reference
val dstConstructor: KFunction<Dst> = ::Dst

// KMapper
val kMapper: KMapper<Dst> = KMapper(dstConstructor)
// PlainKMapper
val plainMapper: PlainKMapper<Dst> = PlainKMapper(dstConstructor)
// BoundKMapper
val boundKMapper: BoundKMapper<Src, Dst> = BoundKMapper(dstConstructor)

Initialize from KClass

The KMapper can also be initialized from the KClass.
By default, the primary constructor is the target of the call.

data class Dst(...)

// KMapper
val kMapper: KMapper<Dst> = KMapper(Dst::class)
// PlainKMapper
val plainMapper: PlainKMapper<Dst> = PlainKMapper(Dst::class)
// BoundKMapper
val boundKMapper: BoundKMapper<Src, Dst> = BoundKMapper(Dst::class, Src::class)

By using a dummy constructor and omitting generics, you can also write as follows.

// KMapper
val kMapper: KMapper<Dst> = KMapper()
// PlainKMapper
val plainMapper: PlainKMapper<Dst> = PlainKMapper()
// BoundKMapper
val boundKMapper: BoundKMapper<Src, Dst> = BoundKMapper()

Specifying the target of a call by KConstructor annotation

When initializing from the KClass, all mapper classes can specify the function to be called by the KConstructor annotation.

In the following example, the secondary constructor is called.

data class Dst(...) {
    @KConstructor
    constructor(...) : this(...)
}

val mapper: KMapper<Dst> = KMapper(Dst::class)

Similarly, the following example calls the factory method.

data class Dst(...) {
    companion object {
        @KConstructor
        fun factory(...): Dst {
            ...
        }
    }
}

val mapper: KMapper<Dst> = KMapper(Dst::class)

Detailed usage

Converting values during mapping

In mapping, you may want to convert one input type to another.
The KMapper provides a rich set of conversion features for such a situation.

However, this conversion can be performed under the following conditions.

  • Input is not null.
    • If null is involved, it is recommended to combine the KParameterRequireNonNull annotation with the default argument.
  • Input cannot be assigned directly to an argument.

Conversions available by default

Some of the conversion features are available without any special description.

1-to-1 conversion (nested mapping)

If you can't use arguments as they are and no other transformation is possible, KMapper tries to do 1-to-1 mapping using the mapping class.
This allows you to perform the following nested mappings by default.

data class InnerDst(val foo: Int, val bar: Int)
data class Dst(val param: InnerDst)

data class InnerSrc(val foo: Int, val bar: Int)
data class Src(val param: InnerSrc)

val src = Src(InnerSrc(1, 2))
val dst = KMapper(::Dst).map(src)

println(dst.param) // -> InnerDst(foo=1, bar=2)
Specifies the function used for the nested mapping

Nested mapping is performed by initializing BoundKMapper from the class.
For this reason, you can specify the target of the call with the KConstructor annotation.

Other conversions
Conversion from String to Enum

If the input is a String and the argument is an Enum, an attempt is made to convert the input to an Enum with the corresponding name.

enum class FizzBuzz {
    Fizz, Buzz, FizzBuzz;
}

data class Dst(val fizzBuzz: FizzBuzz)

val dst = KMapper(::Dst).map("fizzBuzz" to "Fizz")
println(dst) // -> Dst(fizzBuzz=Fizz)
Conversion to String

If the argument is a String, the input is converted by toString method.

Specifying the conversion method using the KConverter annotation

If you create your own class and can be initialized from a single argument, you can use the KConverter annotation.
The KConverter annotation can be added to a constructor or a factory method defined in a companion object.

// Annotate the primary constructor
data class FooId @KConverter constructor(val id: Int)
// Annotate the secondary constructor
data class FooId(val id: Int) {
    @KConverter
    constructor(id: String) : this(id.toInt())
}
// Annotate the factory method
data class FooId(val id: Int) {
    companion object {
        @KConverter
        fun of(id: String): FooId = FooId(id.toInt())
    }
}
// If the fooId is given a KConverter, Dst can do the mapping successfully without doing anything.
data class Dst(
    fooId: FooId,
    bar: String,
    baz: Int?,

    ...

)

Conversion by creating your own custom deserialization annotations

If you cannot use KConverter, you can convert it by creating a custom conversion annotations and adding it to the parameter.

Custom conversion annotation is made by defining a pair of conversion annotation and converter.
As an example, we will show how to create a ZonedDateTimeConverter that converts from java.sql.Timestamp or java.time.Instant to ZonedDateTime in the specified time zone.

Create conversion annotation

You can define a conversion annotation by adding @Target(AnnotationTarget.VALUE_PARAMETER), KConvertBy annotation, and several other annotations.

The argument of the KConvertBy annotation passes the KClass of the converter described below.
This converter should be defined for each source type.

Also, although this example defines an argument to the annotation, you can get the value of the annotation from the converter.

@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@KConvertBy([TimestampToZonedDateTimeConverter::class, InstantToZonedDateTimeConverter::class])
annotation class ZonedDateTimeConverter(val zoneIdOf: String)
Create converter

You can define converter by inheriting AbstractKConverter<A, S, D>.
Generics A,S,D have the following meanings.

  • A: conversion annotation Type.
  • S: Source Type.
  • D: Destination Type.

Below is an example of a converter that converts from java.sql.Timestamp to ZonedDateTime.

class TimestampToZonedDateTimeConverter(
    annotation: ZonedDateTimeConverter
) : AbstractKConverter<ZonedDateTimeConverter, Timestamp, ZonedDateTime>(annotation) {
    private val timeZone = ZoneId.of(annotation.zoneIdOf)

    override val srcClass: KClass<Timestamp> = Timestamp::class

    override fun convert(source: Timestamp): ZonedDateTime = ZonedDateTime.of(source.toLocalDateTime(), timeZone)
}

The argument to the converter's primary constructor should only take a conversion annotation.
This is called when KMapper is initialized.

As shown in the example, you can refer to the arguments defined in the annotation.

Using custom conversion annotations

The conversion annotation and the converter defined so far are written together as follows.
InstantToZonedDateTimeConverter is a converter whose source is java.time.Instant.

@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@KConvertBy([TimestampToZonedDateTimeConverter::class, InstantToZonedDateTimeConverter::class])
annotation class ZonedDateTimeConverter(val zoneIdOf: String)

class TimestampToZonedDateTimeConverter(
    annotation: ZonedDateTimeConverter
) : AbstractKConverter<ZonedDateTimeConverter, Timestamp, ZonedDateTime>(annotation) {
    private val timeZone = ZoneId.of(annotation.zoneIdOf)

    override val srcClass: KClass<Timestamp> = Timestamp::class

    override fun convert(source: Timestamp): ZonedDateTime = ZonedDateTime.of(source.toLocalDateTime(), timeZone)
}

class InstantToZonedDateTimeConverter(
    annotation: ZonedDateTimeConverter
) : AbstractKConverter<ZonedDateTimeConverter, Instant, ZonedDateTime>(annotation) {
    private val timeZone = ZoneId.of(annotation.zoneIdOf)

    override val srcClass: KClass<Instant> = Instant::class

    override fun convert(source: Instant): ZonedDateTime = ZonedDateTime.ofInstant(source, timeZone)
}

When this is given, it becomes as follows.

data class Dst(
    @ZonedDateTimeConverter("Asia/Tokyo")
    val t1: ZonedDateTime,
    @ZonedDateTimeConverter("-03:00")
    val t2: ZonedDateTime
)

Conversion from Multiple Arguments

The KParameterFlatten annotation allows you to perform a transformation that requires more than one argument.

data class InnerDst(val fooFoo: Int, val barBar: String)
data class Dst(val bazBaz: InnerDst, val quxQux: LocalDateTime)

To specify a field name as a prefix, give it as follows.
The class specified with KParameterFlatten is initialized from the function or the primary constructor specified with the aforementioned KConstructor annotation.

data class InnerDst(val fooFoo: Int, val barBar: String)
data class Dst(
    @KParameterFlatten
    val bazBaz: InnerDst,
    val quxQux: LocalDateTime
)
data class Src(val bazBazFooBoo: Int, val bazBazBarBar: String, val quxQux: LocalDateTime)

// required 3 arguments that bazBazFooFoo, bazBazBarBar, quxQux
val mapper = KMapper(::Dst)
KParameterFlatten annotation options

The KParameterFlatten annotation has two options for handling argument names of the nested classes.

fieldNameToPrefix

By default, the KParameterFlatten annotation tries to find a match by prefixing the name of the argument with the name of the prefix.
If you don't want to prefix the argument names, you can set the fieldNameToPrefix option to false.

data class InnerDst(val fooFoo: Int, val barBar: String)
data class Dst(
    @KParameterFlatten(fieldNameToPrefix = false)
    val bazBaz: InnerDst,
    val quxQux: LocalDateTime
)

// required 3 arguments that fooFoo, barBar, quxQux
val mapper = KMapper(::Dst)

If fieldNameToPrefix = false is specified, the nameJoiner option is ignored.

nameJoiner

The nameJoiner specifies how to join argument names and argument names.
For example, if Src is snake_case, the following command is used.

data class InnerDst(val fooFoo: Int, val barBar: String)
data class Dst(
    @KParameterFlatten(nameJoiner = NameJoiner.Snake::class)
    val bazBaz: InnerDst,
    val quxQux: LocalDateTime
)

// required 3 arguments that baz_baz_foo_foo, baz_baz_bar_bar, qux_qux
val mapper = KMapper(::Dst) { /* some naming transformation process */ }

By default, camelCase is specified, and snake_case and kebab-case are also supported.
You can also write your own by creating object which extends the NameJoiner class.

Use with other conversion methods

The KParameterFlatten annotation also works with all the conversion methods introduced so far.
Also, the KParameterFlatten annotation can be used in any number of layers of nested objects.

Set the argument names and field names used for mapping

By default, KMapper searches the source for a field whose name corresponds to the argument name.
On the other hand, there are times when you want to use a different name for the argument name and the source.

In order to deal with such a situation, KMapper provides some functions to set the argument name and field name used during mapping.

Conversion of argument names

With KMapper, you can set the argument name conversion function at initialization. It can handle situations where constant conversion is required, for example, the argument naming convention is camel case and the source naming convention is snake case.

data class Dst(
    fooFoo: String,
    barBar: String,
    bazBaz: Int?
)

val mapper: KMapper<Dst> = KMapper(::Dst) { fieldName: String ->
    /* some naming transformation process */
}

// For example, by passing a conversion function to the snake case, the following input can be handled
val dst = mapper.map(mapOf(
    "foo_foo" to "foo",
    "bar_bar" to "bar",
    "baz_baz" to 3
))

And, of course, any conversion process can be performed within the lambda.

Propagation of the argument name conversion process

The argument name conversion process is also reflected in the nested mapping.
Also, the conversion is applied to the aliases specified with the KParameterAlias annotation described below.

The actual conversion process

Although KMapper does not provide naming transformation, some of the most popular libraries in your project may also provide it.
Here is a sample code of Jackson and Guava that actually passes the "CamelCase -> SnakeCase" transformations.

Jackson
import com.fasterxml.jackson.databind.PropertyNamingStrategy

val parameterNameConverter: (String) -> String = PropertyNamingStrategy.SnakeCaseStrategy()::translate
val mapper: KMapper<Dst> = KMapper(::Dst, parameterNameConverter)
Guava
import com.google.common.base.CaseFormat

val parameterNameConverter: (String) -> String = { fieldName: String ->
    CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, fieldName)
}
val mapper: KMapper<Dst> = KMapper(::Dst, parameterNameConverter)

Set an alias for the getter

It is best to use the KGetterAlias annotation to rename the _foo field of the Scr class only at mapping time in the following code.

data class Dst(val foo: Int)
data class Src(val _foo: Int)

The actual grant is as follows.

data class Src(
    @get:KGetterAlias("foo")
    val _foo: Int
)

Set an alias to an argument name

It is best to use the KParameterAlias annotation if you want to change the name of the _bar field of the Dst class only at mapping time in the following code.

data class Dst(val _bar: Int)
data class Src(val bar: Int)

The actual grant is as follows.

data class Dst(
    @KParameterAlias("bar")
    val _bar: Int
)

Other functions

Control and use default arguments

The KMapper uses the default argument if no argument is given.
Also, if an argument is given, you can control whether to use it or not.

Always use the default arguments

If you want to force a default argument, you can use the KUseDefaultArgument annotation.

class Foo(
    ...,
    @KUseDefaultArgument
    val description: String = ""
)
Use default argument if input is null

The KParameterRequireNonNull annotation skips the input until a non null value is specified as an argument.
By using this, the default argument is used when all the corresponding contents are null.

class Foo(
    ...,
    @KParameterRequireNonNull
    val description: String = ""
)

Ignore the field when mapping

If you want to ignore a field for mapping for some reason, you can use the KGetterIgnore annotation.
For example, if you enter the following class of Src, the param1 field will not be read.

data class Src(
    @KGetterIgnore
    val param1: Int,
    val param2: Int
)

Setting Up Arguments

Target for argument reading

The KMapper can read the public field of an object, or the properties of Pair<String, Any?> and Map<String, Any?>.

Setting Up Arguments

The KMapper performs the setup process if the value is not null.
In the setup process, first of all, parameterClazz.isSuperclassOf(inputClazz) is used to check if the input can be set as an argument or not, and if not, the conversion described later is performed and the result is used as an argument.

If the value is null, the KParameterRequireNonNull annotation is checked, and if it is set, the setup process is skipped, otherwise null is used as the argument.

If the KUseDefaultArgument annotation is set or all inputs are skipped by the KParameterRequireNonNull annotation, the default argument is used.
If the default argument is not available at this time, a runtime error occurs.

Conversion of arguments

KMapper performs conversion and checking in the following order.

1. Checking the specification of the conversion process by annotation First of all, it checks for conversions specified by the KConvertBy and KConverter annotations for the class of the input.

2. Confirmation of conversion to Enum If the input is a String and the argument is an Enum, the function tries to convert the input to an Enum with the corresponding name.

3. Confirmation of conversion to string If the argument is String, the input will be toString.

4. Conversion using the mapper class If the transformation does not meet the criteria so far, a mapping process is performed using a mapper class.
For this mapping process, PlainKMapper is used for PlainKMapper, and BoundKMapper is used for others.

Input priority

The KMapper basically gives priority to the first available argument.
For example, in the following example, since param1 is given first as value1, the next input param1" to "value2" is ignored.

val mapper: KMapper<Dst> = ...

val dst = mapper.map("param1" to "value1", "param1" to "value2")

However, if null is specified as an input for an argument with a KParameterRequireNonNull annotation, it is ignored and the later argument takes precedence.