Search code examples
kotlinreflectiondata-class

Deep merging data classes in Kotlin


How can I do a recursive / deep merge of two data classes in Kotlin? Something like this:

import kotlin.reflect.*
import kotlin.reflect.full.*

data class Address(
  val street: String? = null,
  val zip: String? = null
)

data class User(
  val name: String? = null,
  val age: Int? = null,
  val address: Address? = null
)

inline fun <reified T : Any> T.merge(other: T): T {
  val nameToProperty = T::class.declaredMemberProperties.associateBy { it.name }
  val primaryConstructor = T::class.primaryConstructor!!
  val args = primaryConstructor.parameters.associate { parameter ->
    val property = nameToProperty[parameter.name]!!
    val type = property.returnType.classifier as KClass<*>
    if (type.isData) {
      parameter to this.merge(other) //inline function can't be recursive
    } else {
      parameter to (property.get(other) ?: property.get(this))
    }
  }
  return primaryConstructor.callBy(args)
}

val u1 = User(name = "Tiina", address = Address(street = "Hämeenkatu"))
val u2 = User(age = 23, address = Address(zip = "33100"))

u1.merge(u2)
// expected: User(age = 23, name= "Tiina", address = Address(zip = "33100", street = "Hämeenkatu")

related: Combining/merging data classes in Kotlin


Solution

  • There were several problems in the posted code,

    1. unnecessary reification and inlining
    2. when type isData was detected instead of merging the values of the property merge on this with the other was called, so it became endless recursion.
    3. get cannot be used on KProperty1<out T, Any?> because of the variance
    4. some non-idiomatic stuff which works, but can be made better

    Here's the fixed version. For production I would've added some checks and error messages, but this should work for "happy path" and hopefully give you the base to build on:

    import kotlin.reflect.KClass
    import kotlin.reflect.KParameter
    import kotlin.reflect.KProperty1
    import kotlin.reflect.full.declaredMemberProperties
    import kotlin.reflect.full.isSubclassOf
    import kotlin.reflect.full.primaryConstructor
    
    data class Address(
        val street: String? = null,
        val zip: String? = null
    )
    
    data class User(
        val name: String? = null,
        val age: Int? = null,
        val address: Address? = null,
        val map: Map<String, Int>? = null
    )
    
    fun <T> mergeData(property: KProperty1<out T, Any?>, left: T, right: T): Any? {
        val leftValue = property.getter.call(left)
        val rightValue = property.getter.call(right)
        return rightValue?.let {
            if ((property.returnType.classifier as KClass<*>).isSubclassOf(Map::class)) (leftValue as? Map<*, *>)?.plus(it as Map<*, *>)
            else leftValue?.merge(it)
        } ?: rightValue ?: leftValue
    }
    
    fun <T> lastNonNull(property: KProperty1<out T, Any?>, left: T, right: T) =
        property.getter.call(right) ?: property.getter.call(left)
    
    fun <T : Any> T.merge(other: T): T {
        val nameToProperty = this::class.declaredMemberProperties.associateBy { it.name }
        val primaryConstructor = this::class.primaryConstructor!!
        val args: Map<KParameter, Any?> = primaryConstructor.parameters.associateWith { parameter ->
            val property = nameToProperty[parameter.name]!!
            val type = property.returnType.classifier as KClass<*>
            when {
                type.isData || type.isSubclassOf(Map::class) -> mergeData(property, this, other)
                else -> lastNonNull(property, this, other)
            }
        }
        return primaryConstructor.callBy(args)
    }
    
    
    // verification
    
    val u1 = User(name = "Tiina", address = Address(street = "Hämeenkatu"), map = mapOf("a" to 1))
    val u2 = User(age = 23, address = Address(zip = "33100"), map = mapOf("b" to 2))
    
    check(
        u1.merge(u2) == User(
            age = 23,
            name = "Tiina",
            address = Address(zip = "33100", street = "Hämeenkatu"),
            map = mapOf("a" to 1,"b" to 2)
        )
    ) {
        "doesn't work"
    }
    
    println("Works!")