Kotlin Object Multiplatform Mapper

Introduction

How AI Sees Object Mapping

How AI Sees Object Mapping

I get the impression that I won’t be destined to finish finishing my Android app any time soon. Every time I start writing a new version (since the old one was not fully written, it was used only by me, and after a couple of years of inactivity it is easier to write again) of my application, conceived back in 2012, I am faced with a situation that I am missing something some functionality and I’m starting to write my own libraries for this. In the first attempt it was my own ORM (UcaOrm 1, 2, 3). In the second KCron – KMP library implementing Cron. And now, having started the next iteration, I am again in the same position. But first things first!

Where it all started

This time for development I chose Compose UI. The first thing I did was start studying how all this fits with Room. Came across this one tutorialand at first everything went well, until the step 9. Seeing this code:

/**
* Extension function to convert [ItemDetails] to [Item]. If the value of [ItemDetails.price] is
* not a valid [Double], then the price will be set to 0.0. Similarly if the value of
* [ItemDetails.quantity] is not a valid [Int], then the quantity will be set to 0
*/
fun ItemDetails.toItem(): Item = Item(
    id = id,
    name = name,
    price = price.toDoubleOrNull() ?: 0.0,
    quantity = quantity.toIntOrNull() ?: 0
)

/**
* Extension function to convert [Item] to [ItemUiState]
*/
fun Item.toItemUiState(isEntryValid: Boolean = false): ItemUiState = ItemUiState(
    itemDetails = this.toItemDetails(),
    isEntryValid = isEntryValid
)

/**
* Extension function to convert [Item] to [ItemDetails]
*/
fun Item.toItemDetails(): ItemDetails = ItemDetails(
    id = id,
    name = name,
    price = price.toString(),
    quantity = quantity.toString()
)

my eye twitched a little and, having extensive experience working with AutoMapper‘om, I wondered: “Why is there still no alternative for KMP?“. And since it doesn’t exist, we need to write it!

From idea to start of implementation

The main disadvantage of AutoMapper is known to everyone – it is runtime. To avoid runtime problems, you need to spend extra effort covering tests. However, I very often encountered other problems with its use and even wrote AutoMapper.Analyzers.

In order to avoid the same problems in my new library, I chose code generation. For KMP the choice is small: either outdated KAPTor new KSP. Of course write new There is no point in using a library on something old, so I started studying KSP.

It turned out to be not so complicated, and after a couple of experiments and the formation of the basic ideas of my library, as well as spending some time coming up with a name, I created a new repository KOMM.

KOMM

The main idea of ​​the library: generating an extension method for mapping one class to another.

Let’s assume we have a source class:

class SourceObject {

    val id = 150

    val intToString = 300

    val stringToInt = "250"
}

And the receiver class:

data class DestinationObject(
    val id: Int,
    val stringToInt: Int
) {
    var intToString: String = ""
}

To map one to the other, just mark the receiver class with the KOMMMap annotation:

@KOMMMap(from = SourceObject::class)
data class DestinationObject(
    val id: Int,
    val stringToInt: Int
) {
    var intToString: String = ""
}

As a result, the following extension method will be generated:

fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject(
    id = id,
    stringToInt = stringToInt.toInt()
).also { 
    it.intToString = intToString.toString()
}

Feature of KOMM

As you can see KOMM he himself tries to bring some types to others. You can prevent him from doing this by adding the configuration to the annotation:

@KOMMMap(
    from = SourceObject::class,
    config = MapConfiguration(
        tryAutoCast = false
    )
)

As a result, when you try to display properties without using a converter (more about it below), an exception will be thrown.

Of course, you often need to map properties with one name to properties with another. For this purpose, KOMM has a MapFrom annotation:

class SourceObject {
    //...
    val userName = "user"
}

@KOMMMap(from = SourceObject::class)
data class DestinationObject(
    //...
    @MapFrom("userName")
    val name: String
)

Important note!

KOMM supports multimapping – when several classes can be mapped into one. In this case, for field annotations, you can specify for which specific source class to apply customization:

@KOMMMap(
    from = FirstSourceObject::class
)
@KOMMMap(
    from = SecondSourceObject::class
)
data class DestinationObject(
    @MapFrom("userId", [SecondSourceObject::class])
    val id: Int
)

For others, the default settings will be applied.

For special conversions, you can use a converter. For example:

class CostConverter(source: SourceObject) : KOMMConverter<SourceObject, Double, String>(source) {

    override fun convert(sourceMember: Double) = "$sourceMember ${source.currency}"
}

class SourceObject {
    val cost = 499.99
}

@KOMMMap(from = SourceObject::class)
data class DestinationObject(
    @MapConvert<SourceObject, CostConverter>(CostConverter::class)
    val cost: String
) {
    @MapConvert<SourceObject, CostConverter>(CostConverter::class, "cost")
    var otherCost: String = ""
}

For converters, the generic type acts as a pointer to which source to apply the annotation to.

As a result, the following method will be generated:

fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject(
    cost = CostConverter(this).convert(cost)
).also { 
    it.otherCost = CostConverter(this).convert(cost)
}

As you can see, the entire source object is passed as a property of the converter class, and the source property is also passed to the conversion method.

If a property declared through a constructor cannot be obtained from the source object, it must be annotated with a resolver. The annotation can also be applied to properties declared outside the constructor:

class DateResolver(destination: DestinationObject?) : KOMMResolver<DestinationObject, Date>(destination) {
    
    override fun resolve(): Date = Date.from(Instant.now())
}

@KOMMMap(from = SourceObject::class)
data class DestinationObject(
    @MapDefault<DateResolver>(DateResolver::class)
    val activeDate: Date
) {
    @MapDefault<DateResolver>(DateResolver::class)
    var otherDate: Date = Date.from(Instant.now())
}

The output will be:

fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject(
    activeDate = DateResolver(null).resolve()
).also { 
    it.otherDate = DateResolver(it).resolve()
}

Please note that as a parameter to the resolver’s constructor we receive either null – if the displayed property is declared in the constructor, or an already existing receiver object.

Null substitute is also supported:

class IntResolver(destination: DestinationObject?): KOMMResolver<DestinationObject, Int>(destination) {

    override fun resolve() = 1
}

data class SourceObject(
    val id: Int?
)

@KOMMMap(
    from = SourceObject::class
)
data class DestinationObject(
    @NullSubatitute(MapDefault(IntResolver::class))
    val id: Int
) {
    @NullSubatitute(MapDefault(IntResolver::class), "id")
    var otherId: Int = 0
}

//...

fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject(
    id = id ?: IntResolver(null).resolve()
).also {
    it.otherId = id ?: IntResolver(it).resolve()
}

There are a few more settings and features (as support Java objects with their get-methods, for example), but the article is already very long, so you can read about them in README.

What’s next?

There are several more improvements and improvements planned.

There are also thoughts on the reverse mapping, it seems like MapTobut here the problem begins with Java objects. So I haven’t come up with a good solution yet.

Also in the near future the library will be available through the Maven Central repository.

I would be grateful for any ideas for developing the library or contributing‘A.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *