Kotlin Convert Data Class to Map

December 16, 2021

This is not a straightforward solution, as my use case is slightly differ from the common one.

You will need to add kotlin-reflect.

depedencies {
    implementation "org.jetbrains.kotlin:kotlin-reflect"
}

A lot of the annotation is for Firestore: @IgnoreExtraProperties, @PropertyName, which is not necessary for your use case.

@IgnoreExtraProperties
data class Card(
    override var id: String? = null,
    var modified: Timestamp? = null,
    var title: String? = null,
    @get:PropertyName(IMAGE_IDS) @set:PropertyName(IMAGE_IDS)
    var imageIds: List<String>? = null,
    // var place: Place? = null,
) : FirestoreData {

    companion object {
        const val COLLECTION_NAME = "card"

        const val MODIFIED = "modified"
        const val TITLE = "title"
        // const val PLACE = "place"
        const val IMAGE_IDS = "image_ids"

        fun colRef(firestore: FirebaseFirestore, uid: String) = UserPrivate.colRef(firestore).document(uid).collection(
            COLLECTION_NAME
        )

        fun toObject(doc: DocumentSnapshot): Card? {
            val item = doc.toObject<Card>()
            item?.id = doc.id
            return item
        }
    }

    val isNew: Boolean
        get() = id == null


    override fun getData(filterNull: Boolean): Map<String, Any?> {
        return FirestoreDataUtil.toMap(this, excludeNames = listOf(::COLLECTION_NAME.name), filterNull)
    }
}

An Interfect class to indicate support to convert data class to map.

interface FirestoreData {
    var id: String? // optional
    fun getData(filterNull: Boolean = true) : Map<String, Any?>
}

Process

  • Find all the constant public string in companion object to find all the field names (ability to exclude certain fields). With this method, I can add new property to this class where I don’t want it to be included in the map.
  • Get the fields and their value based on these field names.
  • If object is instance of FirestoreData (like the commented Place field), call getData to get its underlying field.
class FirestoreDataUtil {
    companion object {
        fun toMap(instance: Any, excludeNames: List<String>, filterNull: Boolean = true) : Map<String, Any?> {
            val findMember = instance::class.declaredMemberProperties
                .filter{ it.visibility == KVisibility.PUBLIC }
                .filterIsInstance<KProperty1<Any, *>>()
                .associateBy(
                    {
                        val annotation = it.getter.findAnnotation<PropertyName>()
                        annotation?.value ?: it.name
                    },
                    { it }
                )

            var data = instance::class.companionObject!!.declaredMemberProperties
                .filter { it.visibility == KVisibility.PUBLIC && it.isConst && it.returnType == String::class.createType() && !excludeNames.contains(it.name) }
                .mapNotNull {
                    it.getter.call(this).toString()
                }
                .associateBy(
                    { it },
                    {
                        // raise exception or return null to ignore
                        val prop = findMember[it] ?: throw Exception("$it not found")
                        val value = prop.get(instance)
                        if (value != null) {
                            /*
                            if (value::class.isData) {
                                value::class.memberFunctions.find {  func -> func.name == "toMap" }?.call(value, filterNull) ?: value
                            }

                             */
                            // TODO("Check value is List<FirestoreData> or Map<Any, FirestoreData>")
                            if (value is FirestoreData) {
                                value.getData(filterNull)
                            }
                            else value
                        } else null
                    }
                )


            if (filterNull) {
                data = data.filterValues { it != null }
            }
            return data
        }
    }
}

Discussions

I didn’t use Json or Kotlin Serialization approach because it require each field to be converted into basic type (e.g. Timestamp to epoch milli seconds). I need the value to maintain it’s original type.

Originally I use Kotlin Reflect to find the member fields of the data class, but I might accidentally add a new property (e.g. isNew property) which I don’t intend for it to end up in the map.

This work is licensed under a
Creative Commons Attribution-NonCommercial 4.0 International License.