Having trouble with deserialising JSON nullable fields in Kotlin with custom decoder - kotlin

I am having difficulty decoding this JSON with Kotlin Serialization. It works well when the data field is not empty. However when the data field is null and the errors field is present I get this runtime exception:
kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 7: Expected start of the object '{', but had ':' instead
JSON input: {"data":null,"errors":[{"path":null,"locations":[{"line":3,"column":5,"sourceName":null}],"message":"Validation error of type FieldUndefined: Field 'mee' in type 'Query' is undefined # 'mee'"}]})
The JSON pretty printed:
{
"data": null,
"errors": [{
"path": null,
"locations": [{
"line": 3,
"column": 5,
"sourceName": null
}],
"message": "Validation error of type FieldUndefined: Field 'mee' in type 'Query' is undefined # 'mee'"
}]
}
The code which is mostly stolen from How to serialize a generic class with kontlinx.serialization? :
class ApiResponse<T>(
#SerialName("data")
val data: T? = null,
#SerialName("errors")
val errors: List<ErrorResponse>? = null
)
#Serializable
class ErrorResponse(
val path: String? = null,
val locations: List<Location>? = null,
val errorType: String? = null,
val message: String? = null
)
#Serializable
data class Location(
val line: Int? = 0,
val column: Int? = 0,
val sourceName: String? = null
)
#ExperimentalSerializationApi
class ApiResponseSerializer<T>(private val dataSerializer: KSerializer<T>) : KSerializer<ApiResponse<T>> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ApiResponseDataSerializer") {
val dataDescriptor = dataSerializer.descriptor
element("data", dataDescriptor)
element("errors", ErrorResponse.serializer().descriptor)
}
override fun deserialize(decoder: Decoder): ApiResponse<T> =
decoder.decodeStructure(descriptor) {
var data: T? = null
var errors: List<ErrorResponse>? = null
val listSerializer = ListSerializer(ErrorResponse.serializer())
loop# while (true) {
when (val i = decodeElementIndex(descriptor)) {
0 -> data = decodeSerializableElement(descriptor, i, dataSerializer, null)
1 -> errors = decodeSerializableElement(descriptor, i, ListSerializer(ErrorResponse.serializer()), null)
CompositeDecoder.DECODE_DONE -> break
else -> throw SerializationException("Unknown index $i")
}
}
ApiResponse(data, errors)
}
override fun serialize(encoder: Encoder, value: ApiResponse<T>) {
encoder.encodeStructure(descriptor) {
val listSerializer = ListSerializer(ErrorResponse.serializer())
encodeNullableSerializableElement(descriptor, 0, dataSerializer, value.data)
value.errors?.let {
encodeNullableSerializableElement(descriptor, 1, listSerializer, it)
}
}
}
}
I tried using decodeNullableSerializableElement, but I got a compilation error. I couldn't find a way to fix that.
Type mismatch: inferred type is KSerializer<T> but DeserializationStrategy<TypeVariable(T)?> was expected
Any help would be appreciated, I am very new to Android and Kotlin.

Always pays to come back after a good nights sleep. Not sure why I had so much trouble with decodeNullableSerializableElement yesterday, but today I played around and got it working.
Made 3 changes
Made T optional in class parameter
Added .nullable (could not get this to work yesterday) to both serialisers
Changed decodeSerializableElement to decodeNullableSerializableElement
Relevant changes below:
#ExperimentalSerializationApi
class ApiResponseSerializer<T>(private val dataSerializer: KSerializer<T?>) : KSerializer<ApiResponse<T>> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ApiResponseDataSerializer") {
val dataDescriptor = dataSerializer.descriptor
element("data", dataDescriptor.nullable)
element("errors", ErrorResponse.serializer().descriptor.nullable)
}
override fun deserialize(decoder: Decoder): ApiResponse<T> =
decoder.decodeStructure(descriptor) {
var data: T? = null
var errors: List<ErrorResponse>? = null
val listSerializer = ListSerializer(ErrorResponse.serializer()).nullable
loop# while (true) {
when (val i = decodeElementIndex(descriptor)) {
0 -> data = decodeNullableSerializableElement(descriptor, i, dataSerializer, null)
1 -> errors = decodeNullableSerializableElement(descriptor, i, listSerializer, null)
CompositeDecoder.DECODE_DONE -> break
else -> throw SerializationException("Unknown index $i")
}
}
ApiResponse(data, errors)
}
override fun serialize(encoder: Encoder, value: ApiResponse<T>) {
encoder.encodeStructure(descriptor) {
val listSerializer = ListSerializer(ErrorResponse.serializer())
encodeNullableSerializableElement(descriptor, 0, dataSerializer, value.data)
value.errors?.let {
encodeNullableSerializableElement(descriptor, 1, listSerializer, it)
}
}
}
}

Related

Returning one of different object types from single function in kotlin

I have the following structure at present:
#Entity
#Table(name = "table_app_settings")
data class AppSetting(
#Id
#GeneratedValue(strategy = GenerationType.IDENTITY)
#Column(name = "app_setting_id")
val id: Long? = null,
#Column(name = "app_setting_name")
val name: String = "",
#Column(name = "app_setting_value")
var value: String = "",
#Column(name = "app_setting_type")
val type: AppSettingType,
)
enum class AppSettingType {
CHAR,
STRING,
BYTE,
SHORT,
INT,
LONG,
DOUBLE,
FLOAT,
BOOLEAN,
}
This is then saved to the database with the following:
override fun saveAppSetting(setting: AppSetting): DatabaseResult<AppSetting> {
log.info("Saving App Setting ${setting.name} to database.")
return try {
// Attempt to save the entity to the database. If we do not throw an exception, return success.
val savedSetting = appSettingsRepository.save(setting)
DatabaseResult(
code = ResultCode.CREATION_SUCCESS,
entity = savedSetting
)
} catch(exception: DataAccessException) {
log.error("Unable to save App Setting ${setting.name} to database. Reason: ${exception.message}")
DatabaseResult(
code = ResultCode.CREATION_FAILURE
)
}
}
Now, let's say that I wish to save a Char type to database, I figure I would use the following:
override fun saveAppSetting(name: String, value: Char): DatabaseResult<Char> {
val appSettingResult = saveAppSetting(AppSetting(
name = name,
value = value.toString(),
type = AppSettingType.CHAR,
))
return if(appSettingResult.code != ResultCode.CREATION_FAILURE) {
val entity = getAppSetting<Char>(appSettingResult.entity?.name!!).entity.toString().first()
DatabaseResult(
code = appSettingResult.code,
entity = entity
)
} else {
DatabaseResult(
code = ResultCode.CREATION_FAILURE,
)
}
}
I also figured that I would need to do the following in order to retrieve the correct object type:
override fun getAppSetting(name: String): DatabaseResult<Any?> {
log.info("Getting App Setting $name from database.")
val appSetting = appSettingsRepository.findAppSettingByName(name)
return if(appSetting != null) {
log.info("App Setting $name has ID of ${appSetting.id} within the database")
when(appSetting.type) {
AppSettingType.CHAR -> {
DatabaseResult<Char>(
code = ResultCode.FETCH_SUCCESS,
entity = appSetting.value.first(),
)
}
AppSettingType.STRING -> {
DatabaseResult<String>(
code = ResultCode.FETCH_SUCCESS,
entity = appSetting.value,
)
}
AppSettingType.BYTE -> {
DatabaseResult<Byte>(
code = ResultCode.FETCH_SUCCESS,
entity = appSetting.value.toByte(),
)
}
AppSettingType.SHORT -> {
DatabaseResult<Short>(
code = ResultCode.FETCH_SUCCESS,
entity = appSetting.value.toShort(),
)
}
AppSettingType.INT -> {
DatabaseResult<Int>(
code = ResultCode.FETCH_SUCCESS,
entity = appSetting.value.toInt(),
)
}
AppSettingType.LONG -> {
DatabaseResult<Long>(
code = ResultCode.FETCH_SUCCESS,
entity = appSetting.value.toLong(),
)
}
AppSettingType.DOUBLE -> {
DatabaseResult<Double>(
code = ResultCode.FETCH_SUCCESS,
entity = appSetting.value.toDouble(),
)
}
AppSettingType.FLOAT -> {
DatabaseResult<Float>(
code = ResultCode.FETCH_SUCCESS,
entity = appSetting.value.toFloat()
)
}
AppSettingType.BOOLEAN -> {
DatabaseResult<Boolean>(
code = ResultCode.FETCH_SUCCESS,
entity = appSetting.value.toBoolean()
)
}
}
} else {
log.error("App Setting $name does not seem to exist within the database.")
DatabaseResult(
code = ResultCode.FETCH_FAILURE
)
}
However, when I then wish to use said object, I still have to write something like the following:
val newBarcode = getAppSetting("barcode_value").entity.toString().toInt()
Assuming I've "initialised" barcode_value with a value of 177 (for example).
How can I get the function to return what I need without having to do .toString.to...()?
Yes this all possible, here is a simplified demo, firstly
import kotlin.reflect.KClass
data class AppSetting(
val id: Long? = null,
val name: String = "",
var value: String = "",
val type: AppSettingType,
)
enum class AppSettingType(val clazz: KClass<out Any>) {
CHAR(Char::class),
STRING(String::class),
INT(Int::class),
}
So I added a clazz so from the enum we know the Kotlin type
and now a function to simulate your repository fetch
fun findAppSettingByName(name: String): AppSetting? {
return when(name) {
"Char thing" -> AppSetting(value= "C", type = AppSettingType.CHAR)
"String thing" -> AppSetting(value= "Str", type = AppSettingType.STRING)
"Int thing" -> AppSetting(value= "42", type = AppSettingType.INT)
else -> throw IllegalArgumentException()
}
}
Next in the function declaration I have made it generic with T and for the purposes of the demo removed the DatabaseResult container. Then I added a clazz parameter which is the typical Java way of carrying the required class information into the function:
fun <T : Any> getAppSetting(name: String, clazz: KClass<T>): T? {
val appSetting: AppSetting? = findAppSettingByName(name)
return appSetting?.let {
require(clazz == appSetting.type.clazz) {
"appSetting.type=${appSetting.type.clazz} mismatched with requested class=${clazz}"
}
when (appSetting.type) {
AppSettingType.CHAR -> appSetting.value.first()
AppSettingType.STRING -> appSetting.value
AppSettingType.INT -> appSetting.value.toInt()
} as T
}
}
the as T is important to cast the values into the required return type - this is unchecked but the when() clause should be creating the correct types.
Now let's test it:
val c1: Char? = getAppSetting("Char thing", Char::class)
val s1: String? = getAppSetting("String thing", String::class)
val i1: Int? = getAppSetting("Int thing", Int::class)
println("c1=$c1 s1=$s1 i1=$i1")
val c2: Char? = getAppSetting("Char thing")
val s2: String? = getAppSetting("String thing")
val i2: Int? = getAppSetting("Int thing")
println("c2=$c2 s2=$s2 i2=$i2")
}
The output is
c1=C s1=Str i1=42
c2=C s2=Str i2=42
But how do c2/s2/i2 work, the final part is this function
inline fun <reified T : Any> getAppSetting(name: String) = getAppSetting(name, T::class)
This is reified generic parameters... there is no need to pass the clazz because this can be found from the data type of the receiving variable.
There are many articles about this advanced topic, e.g.
https://typealias.com/guides/getting-real-with-reified-type-parameters/
https://medium.com/kotlin-thursdays/introduction-to-kotlin-generics-reified-generic-parameters-7643f53ba513
Now, I didn't completely answer what you wanted because you wanted to receive a DatabaseResult<T> wrapper. What might be possible, is to have a function that returns DatabaseResult<T> and you can obtain the T from it as the "clazz" parameter, but I'll leave that for someone else to improve on :-) but I think that gets you pretty close.

Unexpected NullPointException with RxKotlin when mapping optionals

To start I have the following Moshi json.
#JsonClass(generateAdapter = true)
data class OrderDetails(
#Json(name = "_id") val id: Int,
#Json(name = "status") val status: String,
#Json(name = "tableNo") val tableNo: Int,
#Json(name = "serverId") val serverId: Int?,
#Json(name = "items") val orderItems: List<OrderDetailsItem>
)
All these fields are expected to have data except for serverId.
This data is fetched from the server where I can allow the user to select order.
onSeletedOrder
.map { it.orderDetails.serverId } //blows up here apparently.
.filterNotNull() //have tried this but it doesn't matter.
.flatMap { findServerBy(it) }
.map { "${it.firstname} ${it.lastname}" }
When I map to the serverId above I blow up with an NPE.
It's interesting that the map (even though it is optional) does an unsafe cast afterwards.
I'd expect it to maintain the optional-ness after the map.
I'm assuming this is because of the bridging backwards to RxJava.
Curious if anyone has a further explanation on why this is.
RxJava does not allow nulls inside the stream. Ideally you would perform this filter before the items enter the stream, but if you can't do that one workaround you could get away with is to use an empty string in place of null.
onSeletedOrder
.map { it.orderDetails.serverId.orEmpty() }
.filter { it.isNotEmpty() }
.flatMap { findServerBy(it) }
.map { "${it.firstname} ${it.lastname}" }
For "map, but exclude some elements", RxJava has flatMapMaybe (other types than Observable can also have it with corresponding return type):
// helper
fun <T> T?.toMaybe(): Maybe<T> = if (this != null) Maybe.just(this) else Maybe.empty<T>
onSeletedOrder
.flatMapMaybe { it.orderDetails.serverId.toMaybe() }
.flatMap { findServerBy(it) }
.map { "${it.firstname} ${it.lastname}" }
The best approach is to format your Data Class with optional data.
#JsonClass(generateAdapter = true)
data class OrderDetails(
#Json(name = "_id") val id: Int? = 0,
#Json(name = "status") val status: String? = "Not active",
#Json(name = "tableNo") val tableNo: Int? = 0,
#Json(name = "serverId") val serverId: Int? = 0,
#Json(name = "items") val orderItems: List<OrderDetailsItem>? = listOf()
)
You won't receive any NPE

How to find the updated fields between a payload and an entity fetched from DB and create an object having fields with updated values and rest Null

Given an update request for a record in DB, I have to find a difference between the payload and existing data in DB then create a new Object which has updated fields with Payload values and rest as Null.
I have created a function which gives me a list of field names which were updated, But I'm unable to create a new object which has values for only these updated fields.The problem is that the function uses "field: Field in cpayload.javaClass.declaredFields" which is kind of generic so I'm unable to set these fields.
fun findupdatedFieldsList(cpayload: Customer, cEntity: Customer): List<String> {
// var customerToPublish = Customer()
val updatedFieldsList: MutableList<String>
updatedFieldsList = ArrayList()
for (field: Field in cpayload.javaClass.declaredFields) {
field.isAccessible = true
val value1 = field.get(cpayload).toString()
val value2 = field.get(cEntity).toString()
!Objects.equals(value1, value2).apply {
if (this) {
// customerToPublish.birthDate=field.get(cpayload).toString()
updatedFieldsList.add(field.name)
}
}
}
return updatedFieldsList
}
#Entity
#Table
data class Customer(
#Id
val partyKey: UUID,
var preferredName: String?,
var givenName: String?,
var lastName: String?,
var middleName: String?,
var emailAddress: String,
var mobileNumber: String,
val birthDate: String?,
val loginOnRegister: Boolean,
var gender: Gender?,
var placeOfBirth: String?,
var createdDate: LocalDateTime = LocalDateTime.now(),
var updatedDate: LocalDateTime = LocalDateTime.now()
)
Desired Output
val customer = Customer(
preferredName = Updated name,
partyKey = partyKey.value,
givenName = Updated name,
lastName = null,
middleName = null,
emailAddress = Updated email,
mobileNumber = null,
birthDate = null,
gender = null,
placeOfBirth = null
)
I was able to construct a solution using Kotlin's reflect. It is generic and can be applied to any Kotlin class that have primary constructor. Unfortunately it won't work with Java classes
You would need to add kotlin-reflect package to your build tool config, e.g. for Gradle:
implementation 'org.jetbrains.kotlin:kotlin-reflect:XXXXXX'
First we will build a function to extract updated properties. Please take a note that we also need to extract properties that are mandatory (non-nullable and without default). We add them to a map of propertyName -> propertyValue:
fun Map<String?, KParameter>.isOptional(name: String) = this[name]?.isOptional ?: false
fun <T : Any> findUpdatedProperties(payload: T, entity: T): Map<String, Any?> {
val ctorParams = payload::class.primaryConstructor!!.parameters.associateBy { it.name }
return payload::class.memberProperties.map { property ->
val payloadValue = property.call(payload)
val entityValue = property.call(entity)
if (!Objects.equals(payloadValue, entityValue) || (!ctorParams.isOptional(property.name))) {
property.name to payloadValue
} else {
null
}
}
.filterNotNull()
.toMap()
}
Then we call this function and construct a new instance of provided class:
fun <T : Any> constructCustomerDiff(clazz: KClass<T>, payload: T, entity: T): T {
val ctor = clazz.primaryConstructor!!
val params = ctor.parameters
val updatedProperties = findUpdatedProperties(payload, entity)
val values = params.map { it to updatedProperties[it.name] }.toMap()
return ctor.callBy(values)
}
Take a note that missing primary constructor will throw NullPointerException because of use of !!.
We could call this funcion as constructCustomerDiff(Customer::class, payload, entity), but we can do better with reified types:
inline fun <reified T : Any> constructCustomerDiff(payload: T, entity: T): T {
return constructCustomerDiff(T::class, payload, entity)
}
Now we can use this function in convenient Kotlin style:
val id = UUID.randomUUID()
val payload = Customer(
partyKey = id,
preferredName = "newName",
givenName = "givenName"
)
val entity = Customer(
partyKey = id,
preferredName = "oldName",
givenName = "givenName" // this is the same as in payload
)
val x = constructCustomerDiff(payload, entity)
assert(x.partyKey == id && x.givenName == null || x.preferredName == "newName")

Kotlin data class create dynamically json of its fields using GSON

I have a data class like this:
data class TestModel(
val id: Int,
val description: String,
val picture: String)
If I create JSON from this data class using GSON and it generates a result like this
{"id":1,"description":"Test", "picture": "picturePath"}
What to do if I need the following JSON from my data class:
{"id":1, "description":"Test"}
And other times:
`{"id":1, "picture": "picturePath"}
`
Thanks in advance!
You can solve this problem with writing custom adapter and with optional types:
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import com.google.gson.stream.JsonWriter
data class TestModel(
val id: Int,
val description: String? = "",
val picture: String? = "")
class TesModelTypeAdapter : TypeAdapter<TestModel>() {
override fun read(reader: JsonReader?): TestModel {
var id: Int? = null
var picture: String? = null
var description: String? = null
reader?.beginObject()
while (reader?.hasNext() == true) {
val name = reader.nextName()
if (reader.peek() == JsonToken.NULL) {
reader.nextNull()
continue
}
when (name) {
"id" -> id = reader.nextInt()
"picture" -> picture = reader.nextString()
"description" -> description = reader.nextString()
}
}
reader?.endObject()
return when {
!picture.isNullOrBlank() && description.isNullOrBlank() -> TestModel(id = id ?: 0, picture = picture)
!description.isNullOrBlank() && picture.isNullOrBlank() -> TestModel(id = id ?: 0, description = description)
else -> TestModel(id ?: 0, picture, description)
}
}
override fun write(out: JsonWriter?, value: TestModel?) {
out?.apply {
beginObject()
value?.let {
when {
!it.picture.isNullOrBlank() && it.description.isNullOrBlank() -> {
name("id").value(it.id)
name("picture").value(it.picture)
}
!it.description.isNullOrBlank() && it.picture.isNullOrBlank() -> {
name("id").value(it.id)
name("description").value(it.description)
}
else -> {
name("id").value(it.id)
name("picture").value(it.picture)
name("description").value(it.description)
}
}
}
endObject()
}
}
}
class App {
companion object {
#JvmStatic fun main(args: Array<String>) {
val tm = TestModel(12, description = "Hello desc")
val tm2 = TestModel(23, picture = "https://www.pexels.com/photo/daylight-forest-glossy-lake-443446/")
val tm3 = TestModel(12, "Hello desc", "https://www.pexels.com/photo/daylight-forest-glossy-lake-443446/")
val gson = GsonBuilder().registerTypeAdapter(TestModel::class.java, TesModelTypeAdapter()).create()
System.out.println(gson.toJson(tm))
System.out.println(gson.toJson(tm2))
System.out.println(gson.toJson(tm3))
}
}
}
Here is actually a way to ignore fields, that are not marked via #Exposed annotation. In order for this to work, special configuration should be used when instantiating Gson. Here is how you can to this.
Easy way is to mark the field as #Transient. Then it would not be either serialized and deserialized.
I want to give you alternative ways without manually serialization/deserialization.
data class TestModel(
val id: Int,
val description: String? = null,
val picture: String? = null)
When you create json from data class
val params = TestModel(id = 1, description = "custom text")
or
val params = TestModel(id = 1, picture = "picture path")
If one of them field is null of data class GSON skips that field
automatically.

Combining/merging data classes in Kotlin

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"))
}
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)
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)
}
}
#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
}
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.
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;