-
Notifications
You must be signed in to change notification settings - Fork 0
Home
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
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
- Mappit
- Shapeshift
- Model Mapper
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
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.
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.
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 }
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") }
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.
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.
The source side may refer to a constant, that will be used
Example:
map { constant(4711) to "number" }
All number types are automatically converted into each other. These are
- Short
- Int
- Long
- Double
- Float
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
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.
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 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") }
})
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()}
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, null
s 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())