1

I maintain an application which helps users to track the progress of an activity in real time. In essence, we represent the state of said activity as a data class in the Android mobile app. Every time some aspect of the activity changes, either in the client or the server, the whole representation of the activity is sent as the body of an API request or response. What would be an effective way to instead only send and receive the aspects of the activity state (i.e., the properties of the instance of the data class that are meant to be updated)?

For example, having the following:

data class Contact(val firstName: String, val middleName: String?, val lastName: String) 

And an instance like below:

Contact(firstName=John, middleName=null, lastName=Doe)

We receive:

{ firstName: "Jane" } 

Then how do we obtain...?

Contact(firstName=Jane, middleName=null, lastName=Doe)

Of course this is a very simple example, but consider data classes with many more fields and properties with more complex types.

It seems to me quite a general problem to solve, but I have not found any references on what a practical approach could be. A few high-level approaches came to mind:

  1. Have a diff data class which was identical to the original but which only had optional (nullable) parameters and then a function that could copy the original instance replacing only the values set in the diff.
  2. Represent the difference as something other than a data class, like a map or a json object, and manually map and (de)serialize each value into a copy of the original instance.

The main problem with the first approach is that it does not immediately solve the scenario in which the change consists of a property being unset (i.e., set to null) which may require a workaround, like wrapping nullable properties as optional in the diff class. The second approach may be more flexible, but it will likely require more code and be harder to maintain.

0

2 Answers 2

1

I came up with a solution based on reflection.

It may have some flaws, but I guess the core idea could be helpful.

Current limitations I see:

  • It doesn’t support nested diffs (e.g., changes like "address.street" won’t be detected)
  • It only works with the primary constructor

Code to apply the diff:

fun <T: Any> applyDiff(obj: T, diff: Map<String, Any?>): T {
    val kClass = obj::class
    val constructor = kClass.primaryConstructor ?: error("Class must have a primary constructor")
    val args = constructor.parameters.associateWith { param ->
        val prop = kClass.memberProperties.find { it.name == param.name } ?: error("No property found for ${param.name}")
        val oldValue = prop.getter.call(obj)
        if (param.name in diff) diff[param.name] else oldValue
    }
    return constructor.callBy(args)
}

The long version of the same logic that includes validation and accepts pairs:

class Diff(val diff: Map<String, Any?>) {
    constructor(vararg pairs: Pair<String, Any?>) : this(mapOf(*pairs))

    fun <T : Any> apply(obj: T): T {
        val kClass = obj::class
        validateDiff(kClass)
        val constructor = kClass.primaryConstructor ?: error("Class must have a primary constructor")
        val args = constructor.parameters.associateWith { param ->
            val prop = kClass.memberProperties.find { it.name == param.name } ?: error("No property found for ${param.name}")
            val oldValue = prop.getter.call(obj)
            if (param.name in diff) diff[param.name] else oldValue
        }

        return constructor.callBy(args)
    }

    private fun validateDiff(kClass: KClass<*>) {
        val contactPropToType = getPropsToType(kClass)
        diff.forEach { (propName, newValue) ->
            val type = contactPropToType[propName] ?: error("Diff contains unknown property: $propName")
            if (!isValueOfType(newValue, type)) {
                val actual = if (newValue != null) newValue::class else "null"
                error("Diff contains value of wrong type for property $propName. Expected: $type, actual: $actual")
            }
        }
    }

    private fun getPropsToType(kClass: KClass<*>): Map<String, KType> = kClass.memberProperties.associate { prop ->
        prop.name to prop.returnType
    }

    private fun isValueOfType(value: Any?, type: KType): Boolean {
        if (value == null) return type.isMarkedNullable

        val valueClass = value::class
        return valueClass.createType() == type || valueClass.createType(nullable = true) == type
    }
}

The tests

data class Contact(val firstName: String, val middleName: String?, val lastName: String, val address: Address? = null)

data class Address(val street: String, val houseNumber: Int)

class DiffTest {

    val original = Contact("John", "Doe", "Smith")

    @Test
    fun `single value diff apply`() {
        val diff = Diff("firstName" to "Jane")
        val updated = diff.apply(original)
        assertEquals(original.copy(firstName = "Jane"), updated)
    }

    @Test
    fun `null value diff apply`() {
        val diffWithNull = Diff("middleName" to null)
        val updated = diffWithNull.apply(original)
        assertEquals(original.copy(middleName = null), updated)
    }

    @Test
    fun `multiple value diff apply`() {
        val diffMultipleProps = Diff(
            "firstName" to "John",
            "middleName" to null,
            "lastName" to "Snow"
        )
        val updated = diffMultipleProps.apply(original)
        assertEquals(Contact("John", null, "Snow"), updated)
    }

    @Test
    fun `custom type value diff apply`() {
        val diffCustomProp = Diff("address" to Address("Main street", 123))
        val updated = diffCustomProp.apply(original)
        assertEquals(original.copy(address = Address("Main street", 123)), updated)
    }

    @Test
    fun `error if unknown property`() {
        val diff = Diff("unknown" to "value")
        assertThrows<IllegalStateException> { diff.apply(original) }
    }

    @Test
    fun `error if unexpected type`() {
        val diff = Diff("firstName" to 100)
        assertThrows<IllegalStateException> { diff.apply(original) }
    }

    @Test
    fun `error if expected not nullable type`() {
        val diff = Diff("firstName" to null)
        assertThrows<IllegalStateException> { diff.apply(original) }
    }
}
Sign up to request clarification or add additional context in comments.

Comments

0

I went ahead and put together an example of the first approach considered in the question simply to illustrate some of the caveats:

data class Optional<T>(val value: T)
interface Contact {
    val firstName: String?
    val middleName: String?
    val lastName: String?
    val age: Int?

    data class Impl(
        override val firstName: String,
        override val middleName: String?,
        override val lastName: String,
        override val age: Int?,
    ) : Contact

    data class Diff(
        override val firstName: String? = null,
        private val optMiddleName: Optional<String?>? = null,
        override val lastName: String? = null,
        private val optAge: Optional<Int?>? = null
    ) : Contact {
        override val middleName: String?
            get() = optMiddleName?.value

        override val age: Int?
            get() = optAge?.value

        private fun <T> Optional<T>?.get(default: T) = when (this) {
            null -> default
            else -> value
        }

        fun applyTo(impl: Impl): Impl = Impl(
            firstName = firstName ?: impl.firstName,
            middleName = optMiddleName.get(impl.middleName),
            lastName = lastName ?: impl.lastName,
            age = optAge.get(impl.age)
        )
    }
}

val impl = Contact.Impl("John", "Albert", "Doe", 40)
val diff = Contact.Diff("Jane", Optional(null))
diff.also { println("$it") }
    .applyTo(impl.also { println("Original: $it") })
    .also { println("Updated: $it") }

The output would be:

Diff(firstName=Jane, optMiddleName=Optional(value=null), lastName=null, optAge=null)
Original: Impl(firstName=John, middleName=Albert, lastName=Doe, age=40)
Updated: Impl(firstName=Jane, middleName=null, lastName=Doe, age=40)

Things to note about this implementation:

  • The common interface between the implementation and the class uses nullable types so the diff class can set them as nullable while the impl class can set them as required.

  • To be able to have nullable types that can be unset when applying the diff I used a wrapper (Optional) in the diff class and used custom accessors for the nullable properties.

  • An extension function on Optional gets the value of optional if set or the default value otherwise.

  • I avoided using the data class' copy method to make sure all fields are mapped.

  • For nested data classes I imagine I would have to call their mergeWith method from the parent's extension method.

It helps that the nullability of the properties of the interface can be overwritten, which I wasn't aware of when I posted the question. I could easily make a custom serializer for the conversion to JSON. Still, this approach requires quite a bit of boiler-plate code, which is why I am curious to see if anyone has a more elegant take on the problem.

1 Comment

Update: here is an article on how to implement optional serialization using kotlinx.serialization that can be used in combination with this approach.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.