Skip to content
Andreas Ernst edited this page Apr 15, 2024 · 18 revisions

Introduction

Mappit is a pure Kotlin object mapper framework used to avoid boilerplate and error prone manual mapping code vastly simplifying the implementation of typical use-cases ( DTO/Entity conversions, etc. )

The basic idea is to rely on a declarative approach to specify the mapping logic. In contrast to various other - mostly java based - implementations, it is completely non-invasive and does not require any changes to the affected classes ( e.g. by adding annotations ).

The other unique detail, that it supports complex data structures on the target side of any level, including classes, that set val properties as constructor arguments only.

Let's look at a simple example:

Assuming classes ProductEntity, ProductTO, PartEntity, PartTO and an immutable data class Money, the following DSL like call would specify the required operations

val mapper = mapper {
    mapping(ProductEntity::class, ProductTO::class) {
        map { "priceCurrency" to path("price", "currency") }
        map { "priceValue" to path("price", "value") }
        map { "parts" to "parts" deep true }
        map { matchingProperties() except properties("parts") }
     }

    mapping(PartEntity::class, PartTO::class) {
        map { matchingProperties() }
     }
}

and could be used by calling one of the various map methods, such as

val result = mapper.map<ProductTO>(productEntity)

Implemented features are

  • fully type-safe. All operation specifications are verified according to the involved types
  • with the help of lambdas and infix operators a quite readable DSL like spec
  • one-to-one and wild-card mappings
  • integration of constant values as sources
  • mapping of paths as source or target operations
  • filling of complex target structures, including handling of immutable classes ( by collecting constructor arguments )
  • support of deep mappings, including handling of cycles
  • mapping of different collection types in each other ( list, array, ... )
  • inheritance of mapping definitions
  • automatic conversions of the different primitive types ( short, float, etc. )
  • manual conversions inside a mapping or as a general rule in a mapper

Performance

As this topic is often discussed, Mappit is pretty FAST!

The base implementation based on reflection is already fast enough due to extensive caching. An optimized version, which translates the low-level operations to java - with the help of javassist - gains another factor 10 which brings it near manual coded operations.

A simple benchmark copying an object with 10 properties was executed with

100000 loops took

Library Time Avg
ModelMapper 522ms 0.00522
ShapeShift 56ms 5.6E-4
Mappit 10ms 1.0E-4

Let's take a look at the details

Mapper Definition

A mapper is the top-level object that takes care of mapping different objects. For each object combination ( source and target ) appropriate mapping operations need to be declared by specifying mappings via a DSL.

Example:

mapper {
   mapping(<source-class>, <target-class>) {
      // ...
   }

   ...
}

Different mappings are necessary as soon as we require different object kinds to be mapped deep. The mapper has to include the transitive closure of all reached object types.

Mapping

A mapping describes the operations required to map a source object in a target object. It will included a number of map functions that specify them

Example:

mapping(<source-class>, <target-class>) {
   map { "name" to "name" }
   ...
}

Let's look at the different possibilities.

One-To-One Mappings

map { <source-property> to <target-property> }

The properties are either specified as strings or as property references, e.g.

map { "name" to "name" }
map { Foo::name to Foo::name }

Matching Properties

Often, source and target share the same property names. For this use-case you can specify wildcard operations.

All matching properties of the source and target class

map { matchingProperties() }

All matching named properties of the source and target class

map { matchingProperties("foo", "bar") }

This can be combined with an except clause

map { matchingProperties("foo", "bar") except properties("bar") }

Deep Mapping

By adding "deep true", the mapper will try to find the corresponding mapping for the referenced source object recursively.

This works for both single valued and multi valued properties. In the second case, the most common collection and array types are supported.

The mapper will keep track of mapped objects. Whenever an already mapped source object is mapped the second time, the previous result is returned instead of reexecuting the mapping. This logic is necesarry to treat cycles correctly.

Path mapping

Both source and target specifications may include a path made up of a list of properties.

map { path("price", "value") to path ("price", "value") }

The mapper will try to construct the target instances in the correct order, given the correct arguments. It supports both mutable and immutable properties. If immutable properties are to be written, it will try to figure out the appropriate constructor.

Example:

data class Money(val currency: String, val value: Long)

In this case, it will look for the appropriate constructor given the two arguments and call it accordingly. If not all arguments are supplied, it will throw an exception of course.

Constant mapping

The source side may refer to a constant, that will be used

Example:

map { constant(4711) to "number" }

Automatic conversions

All number types are automatically converted into each other. These are

  • Short
  • Int
  • Long
  • Double
  • Float

Manual conversions

If specific operations require a conversion, it can be added in the map clause

   map { "id" to "id" convert {obj: String -> obj.toInt() }}

based on the type alias

typealias Conversion<I, O> = (I) -> O

Global Conversions

In case of globally applicable conversions, they can easily be added to the surrounding mapper

mapper {
   register<Short,Float> {value -> value * 2f}

   // mappings
}

and will be applied, whenever source and target types do not match.

Finalizer

A finalizer

typealias Finalizer<S, T> = (S,T) -> Unit

can be used to add some finishing touches to a mapping which will be called after having executed all operations. The corresponding clause is part of the mapping

Example:

mapping(Foo::class, Bar::class) {
   // map...

   finalize {source, target -> target.x = source.y}
}

Mapping inheritance

Mapping definitions can inherit from each other. This makes sense if the corresponding classes also form a hierarchy.

Example: Given derived classes Base and Derived

val baseMapping = mapping(Base::class,Base::class) {
            map { properties() }
}

val mapper = Mapper(
     mapping(Derived::class, Derived::class) {
         derives(baseMapping) // inherit all operations

         map { properties("name") }
      })

Synchronization of collections

In typical CRUD use-cases, server side relations often need to adjust to updated transport objects by comparing the different collections and figuring out

  • what has been added
  • what has been deleted, and
  • what items are identical

based on the notion of primary keys.

In order to integrate this logic, a base class RelationSychronizer is implemented that defines a number of callback methods

abstract class RelationSynchronizer<S:Any, T:Any, PK> protected constructor(private val toPK: PKGetter<S,PK>, private val pk: PKGetter<T,PK>) {
    // return a missing object on target side, which will be added to the collection
    protected abstract fun provide(source: S, context: Mapping.Context): T

    // delete an item in the target collection
    protected open fun delete(target: T) {}

    // possibly update a target item
    protected open fun update(target: T, source: S, context: Mapping.Context) {}


   ...
}

The synchronizer is added in the map clause

 map { "bars" to "bars" synchronize BarSynchronizer()}

Mapping

The mapping process is initiated by different map calls

fun <T:Any>map(source: Any?): T?

maps a single object and returns the result. As you can see, nulls are supported as well, which simply return -well - null as a result.

fun <S:Any,T:Any>map(source: S?, target: T): T?

maps an object on an existing target.

fun <T:Any>map(source: List<Any?>): List<T?>

maps a list objects and return the target list.

The mapping process internally keeps a Context object, which keeps track of different technical aspects, starting with a map of all mapped objects.

If a mapping process needs to be continued from the outside ( which is the case in the synchronization logic ) additional map methods are exposed that accept an additional context argument.

fun <T:Any> map(source: Any?, context: Mapping.Context): T?
fun <T:Any> map(source: Any?, target: T, context: Mapping.Context): T?

If the context needs to be created outside of the first map-method, you can call

 fun createContext() : Mapping.Context 

So, those calls are equivalent

var result = map(source)

and

result = map(source, mapper.createContext())