4

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

Tommi Reiman
  • 268
  • 1
  • 6
  • Firstly, what do you mean by deep merge? What do you expect to happen if u1 and u2 both define a name? Should it take the name of u1 or u2? – Thomas Cook Jan 28 '20 at 15:18
  • Secondly, do you really need to make a generic "deep merge" (i.e. one that will work for all data types?). If you really ask yourself "do I need this super generalized solution right now?" the answer will most likely be no. If you really do need a generalized solution that will deep merge 2 objects then it does look like you are going down a somewhat sensible route. – Thomas Cook Jan 28 '20 at 15:21
  • 1
    In case of both have a field defined, the last non-null wins. Nested data classes should be merged recursively. The data class hierarchies are deep in my case, so not looking for doing that manually, need a generic solution. Coming from Clojure, here's a dynamic deep-merge: https://github.com/weavejester/medley/blob/master/src/medley/core.cljc#L213-L234 – Tommi Reiman Jan 28 '20 at 15:48

1 Answers1

6

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!")
Czar
  • 1,633
  • 2
  • 17
  • 32
  • awesome, thanks! much simpler and works. One comment: the last line in `mergeValues` should probably be: ``` return rightValue?.let { r -> leftValue?.let { l -> r.merge(l)) } ?: r } ?: leftValue ``` so that we use the non-null value here too. If you think that's correct, could you update the example for future seekers? – Tommi Reiman Jan 29 '20 at 14:54
  • 1
    Right you are, I missed that case. Updated the code, although a bit differently than suggested—for better readability. It is advisable to avoid nesting scoping functions too much. Improves readability and reduces probability of mistakes during refactoring. And I'm glad I could help :) – Czar Jan 29 '20 at 16:12
  • Great solution. I was hoping though this would work with maps but it doesn't. `data class User(val name: String? = null, val items: Map? = null)` `User(items = mapOf("monitor" to 1))` `User(name = "George", items = mapOf("laptop" to 2))` – Emanuel George Hategan Aug 05 '20 at 16:31
  • @EmanuelGeorgeHategan supporting maps is a trivial addition here, I've updated the answer to showcase that as well. – Czar Aug 06 '20 at 19:28