6

Is there a way to merge kotlin data classes without specifying all the properties?

data class MyDataClass(val prop1: String, val prop2: Int, ...//many props)

with a function with the following signature:

fun merge(left: MyDataClass, right: MyDataClass): MyDataClass

where this function checks each property on both classes and where they are different uses the left parameter to create a new MyDataClass.

Is this possible possible using kotlin-reflect, or some other means?

EDIT: more clarity

Here is a better description of what i want to be able to do

  data class Bob(
        val name: String?,
        val age: Int?,
        val remoteId: String?,
        val id: String)

@Test
fun bob(){

    val original = Bob(id = "local_id", name = null, age = null, remoteId = null)
    val withName = original.copy(name = "Ben")
    val withAge = original.copy(age = 1)
    val withRemoteId = original.copy(remoteId = "remote_id")

    //TODO: merge without accessing all properties
    // val result = 
    assertThat(result).isEqualTo(Bob(id = "local_id", name = "Ben", age=1, remoteId = "remote_id"))
}
Parker
  • 8,539
  • 10
  • 69
  • 98
Ben Flowers
  • 1,434
  • 7
  • 21
  • 49

5 Answers5

17

If you want to copy values from the right when values in the left are null then you can do the following:

inline infix fun <reified T : Any> T.merge(other: T): T {
    val propertiesByName = T::class.declaredMemberProperties.associateBy { it.name }
    val primaryConstructor = T::class.primaryConstructor
        ?: throw IllegalArgumentException("merge type must have a primary constructor")
    val args = primaryConstructor.parameters.associateWith { parameter ->
        val property = propertiesByName[parameter.name]
            ?: throw IllegalStateException("no declared member property found with name '${parameter.name}'")
        (property.get(this) ?: property.get(other))
    }
    return primaryConstructor.callBy(args)
}

Usage:

data class MyDataClass(val prop1: String?, val prop2: Int?)
val a = MyDataClass(null, 1)
val b = MyDataClass("b", 2)
val c = a merge b // MyDataClass(prop1=b, prop2=1)
mfulton26
  • 29,956
  • 6
  • 64
  • 88
  • 2
    This can also be used with non-data classes, if the primary constructor includes the fields you want to merge. – urgentx Apr 02 '18 at 05:30
  • @AbhiMuktheeswarar this solution does rely on reflection so proguard won't find any static references in the code to some of these members, you'll need to find which members are not available and then instruct proguard to not strip those. – mfulton26 Nov 07 '19 at 19:57
  • 3
    Maybe I should have commented properly. It doesn't work (NullPointerException) - at least in my case with r8 enabled. After removing the force unwrap of nullable variables & making the return type as nullable. It was working. – Abhi Muktheeswarar Nov 08 '19 at 07:41
  • Those forced unwraps are dangerous. Bound to lead to null pointer exceptions – Inn0vative1 May 22 '20 at 03:02
  • @Inn0vative1 can you elaborate? I'm guessing you are referring to the `!!`. A `data class` will always have a `primaryConstructor` and the code only looks up properties by their names so I think both `!!` are "safe". There may certainly be a better way to write this though these days possibly leverage more recent additions to Kotlin too. – mfulton26 May 22 '20 at 15:18
  • @mfulton26 if you can an improve your answer it would be great. I am also facing a similar issue with proguard. – Michael Hathi May 29 '20 at 12:27
  • I've attempted to improve the code by throwing specific errors instead of using `!!` but in order for a merge to work there 1) must be a primary constructor present on the type and 2) property lookups must work as expected. From what I understand all parameters on the primary constructor of a `data class` `constructor` should also be `declaredMemberProperties` on the `data class` itself, if this is not the case then merging cannot be done. I may be missing something. I am open to more feedback/suggestions. Thanks. – mfulton26 May 29 '20 at 15:12
  • at building time it says Unresolved reference: declaredMemberProperties but I can see that the method should be present also in the documentation. any ideas why this could happen? – Pedro Teran Nov 11 '20 at 22:24
  • @PedroTeran you will need `kotlin-reflect` on your classpath, that or something similar could be the issue – mfulton26 Nov 12 '20 at 00:07
2

A class-specific way to combine data classes when we can define the fields we want to combine would be:

data class SomeData(val dataA: Int?, val dataB: String?, val dataC: Boolean?) {
    fun combine(newData: SomeData): SomeData {        
        //Let values of new data replace corresponding values of this instance, otherwise fall back on the current values.
        return this.copy(dataA = newData.dataA ?: dataA,
                dataB = newData.dataB ?: dataB,
                dataC = newData.dataC ?: dataC)
    }
}
urgentx
  • 3,832
  • 2
  • 19
  • 30
1

@mfulton26's solution merges properties that are part of primary constructor only. I have extended that to support all properties

inline infix 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]!!
        parameter to (property.get(other) ?: property.get(this))
    }
    val mergedObject = primaryConstructor.callBy(args)
    nameToProperty.values.forEach { it ->
        run {
            val property = it as KMutableProperty<*>
            val value = property.javaGetter!!.invoke(other) ?: property.javaGetter!!.invoke(this)
            property.javaSetter!!.invoke(mergedObject, value)
        }
    }
    return mergedObject
}
Varunkumar Nagarajan
  • 1,807
  • 1
  • 24
  • 43
  • This Code not marge class inside a class marge. I was hoping this would work with let's call them deep classes? data class Some(val a: String?, val b: String?) data class Other(val some: Some?, val c: String?, val d: String?) when i do Other(Some("a", null), "c", null).merge(Other(Some(null, "b"), null, "d)) I'd expect to get Other(Some("a", "b"), "c", "d") – Nalawala Murtuza Apr 09 '21 at 16:07
0

Your requirements are exactly the same as copying the left value:

fun merge(left: MyDataClass, right: MyDataClass) = left.copy()

Perhaps one of use isn't properly understanding the other. Please elaborate if this isn't what you want.

Note that since right isn't used, you could make it a vararg and "merge" as many as you like :)

fun merge(left: MyDataClass, vararg right: MyDataClass) = left.copy()

val totallyNewData = merge(data1, data2, data3, data4, ...)

EDIT

Classes in Kotlin don't keep track of their deltas. Think of what you get as you're going through this process. After the first change you have

current = Bob("Ben", null, null, "local_id")
next = Bob(null, 1, null, "local_id")

How is it supposed to know that you want next to apply the change to age but not name? If you're just updating based on nullability, @mfulton has a good answer. Otherwise you need to provide the information yourself.

Ruckus T-Boom
  • 4,566
  • 1
  • 28
  • 42
  • The intention of this is to be used in a function where a number of operations have been applied to a data object. Where the operations return a copy of the object with some updated field. lets say ` val a = update1(dataclass)` `val b = update2(dataclass)` where each of these functions happen in parallel. Now i need to merge the values of dataclass, a and b where i would like to do something like `listOf(a,b).reduce(dataclass) { acc, i -> merge(i, acc) } )` – Ben Flowers Jun 15 '17 at 14:56
-2
infix fun <T : Any> T.merge(mapping: KProperty1<T, *>.() -> Any?): T {
    //data class always has primary constructor ---v
    val constructor = this::class.primaryConstructor!!
    //calculate the property order
    val order = constructor.parameters.mapIndexed { index, it -> it.name to index }
                                      .associate { it };

    // merge properties
    @Suppress("UNCHECKED_CAST")
    val merged = (this::class as KClass<T>).declaredMemberProperties
                                           .sortedWith(compareBy{ order[it.name]})
                                           .map { it.mapping() }
                                           .toTypedArray()


    return constructor.call(*merged);
}

Edit

infix fun <T : Any> T.merge(right: T): T {
    val left = this;
    return left merge mapping@ {
        //    v--- implement your own merge strategy
        return@mapping this.get(left) ?: this.get(right);
    };
}

Example

val original = Bob(id = "local_id", name = null, age = null, remoteId = null)
val withName = original.copy(name = "Ben")
val withAge = original.copy(age = 1)
val withRemoteId = original.copy(remoteId = "remote_id")

val result = withName merge withAge merge withRemoteId;
holi-java
  • 29,655
  • 7
  • 72
  • 83
  • This is really good, but doesnt work as get an illegal arument exception in the case of: `data class Bob(val name: String?,val age: Int?,val id: String)` because memberProperties comes back in the order or age, id name which is the wrong order for the constructor and consequently blows up in the construction! – Ben Flowers Jun 15 '17 at 14:01
  • `infix fun T.merge(mapping: KProperty1.() -> Any?): T { val kClass = this::class as KClass val constructorParameters = kClass.primaryConstructor!!.parameters val props = kClass.memberProperties val merged: Array = constructorParameters.map { cParam -> props.find { it.name == cParam.name }?.mapping() }.toTypedArray() return this::class.primaryConstructor!!.call(*merged); }` This seems to have fixed my problem. Although not sure how viable this is as a solution in terms of performance @holi-java – Ben Flowers Jun 15 '17 at 14:50
  • @BenFlowers sir, how about it now? – holi-java Jun 15 '17 at 15:29
  • Thanks for this. I also had to add .filter { order.containsKey(it.name) } on the declaredMemberProperties since I have one property which is not part of the constructor. – rdmurphy Aug 08 '17 at 18:45