Search code examples
kotlindebuggingconstructoryamlannotations

Mapping between properties with distinct names between domain class and YAML file


I'm creating a YAML parser and one of the features I want to implement is that properties of the domain class should be able to have names different from those used in the YAML representation. For example, a property in YAML may have the name city of birth, while in Kotlin, it might be named from. So, to address the mapping between properties with distinct names, I implemented a YamlArg annotation that can be used on the parameters of a domain class constructor, indicating the corresponding name in YAML (e.g., @YamlArg("city of birth")) However, I can not for the life of me manage to get the annotations to have any kind of value. In line 95 of AbstractYamlParser I perform a search in the domain class constructor to find the properties whose name match the key from the yaml file. It is able to find the properties which match the name but not the one where I try to call the annotation class. val paramType = type.memberProperties.find{ it.name == key || it.findAnnotation<YamlArg>()?.name == key } So, while debugging I see that memberProperties has all the properties of Student class but obviously the name of the property where I call the annotation class doesn't match with the input YAML file. So in summary, I'm not sure how exactly I can achieve passing a different name in the YAML file to the class file. I'll leave both the Student class, YamlArg annotation class and the test I am trying to run below. Will also leave both my parser files, any help is very much appreciated!

class Student @JvmOverloads constructor (
    val name: String,
    val nr: Int,
    @YamlArg("city of birth") val from: String,
    @YamlConvert(YamlToDate::class) val birth: LocalDate? = null,
    val address: Address? = null,
    val grades: List<Grade> = emptyList()
)

annotation class YamlArg(val name:String)
fun `Test YamlParserReflect with @YamlArg`() {
        val yaml = """
            name: John
            nr: 123
            city of birth: New York
        """.trimIndent()

        val student = YamlParserReflect.yamlParser(Student::class).parseObject(yaml.reader())

        assertEquals("John", student.name)
        assertEquals(123, student.nr)
        assertEquals("New York", student.from)
    }
import java.io.Reader
import kotlin.contracts.contract
import kotlin.reflect.KClass
import kotlin.reflect.full.*

abstract class AbstractYamlParser<T : Any>(val type: KClass<T>) : YamlParser<T> {
    /**
     * Used to get a parser for other Type using this same parsing approach.
     */
    abstract fun <T : Any> yamlParser(type: KClass<T>) : AbstractYamlParser<T>
    /**
     * Creates a new instance of T through the first constructor
     * that has all the mandatory parameters in the map and optional parameters for the rest.
     */
    abstract fun newInstance(args: Map<String, Any>): T

    final override fun parseObject(yaml: Reader): T {

        // Mapa para guardar os parâmetros do Objeto a criar
        val parameters: MutableMap<String, Any> = mutableMapOf()

        // Variáveis para controlar a identação do ficheiro YAML
        var count = 0
        var canCount = true

        // Ficheiro de texto
        val lines = yaml.readLines()
        var idx = 0

        while ( idx < lines.size ){

            val line = lines[idx]

            // Se a linha for branca, não há nada a analizar
            if(line.isBlank()) { idx++ }
            else {

                // Verificar o número de espaços devido à identação YAML
                if(canCount){
                    for(char in line) if(char == ' ') count++ else break
                    canCount = false
                }

                // Novo objeto
                if(line.trim().endsWith(":")){

                    // Lista para guardar os parâmetros do objeto
                    val newObject: MutableList<String> = mutableListOf()
                    // Nome do parâmetro
                    val key = line.trim().dropLast(1) // Elimina os ':'

                    // Verificamos se existe um parâmetro no construtor com o mesmo nome do parâmetro
                    val obj = type.memberProperties.find { it.name.lowercase() == key }
                        ?: throw IllegalArgumentException("Object '$key' was not found in class '${type.simpleName}'.")

                    // Verificamos a classe do parâmetro
                    // Caso este seja uma lista, pretendemos criar objetos com o tipo dos parâmetros da lista
                    val objClass =
                        if(obj.returnType.classifier == List::class){ obj.returnType.arguments.first().type!!.classifier as KClass<*> }
                        else{ obj.returnType.classifier as KClass<T> }

                    idx++

                    // Percorremos pelos parâmetros do objeto e adicionamo-los à lista 'newObject'
                    while( idx < lines.size ){

                        // Quando a linha já não tiver um elemento do objeto atual
                        if(!lines[idx].drop(count).startsWith(" ")) break

                        // Adicionar o parâmetro ao objeto atual
                        newObject.add(lines[idx])
                        idx++
                    }

                    // Criamos o novo objeto chamando a função com o tipo pretendido e os parâmetros recolhidos
                    parameters[key] = YamlParserReflect.yamlParser(objClass).parseObject(newObject.joinToString("\n").reader())
                }
                else if (line.trim().startsWith("-")){

                    // Caso se trate de uma lista, chamamos o 'parseList'
                    return parseList(lines.joinToString("\n").reader()) as T
                }
                else {

                    val words = line.split(": ")

                    // Dividimos a linha para obter strings com o parâmetro 'key' e o seu valor 'value'
                    val key = words[0].drop(count)
                    val strValue = words[1]

                    // Procuramos no construtor da classe por um parâmetro com o mesmo nome da 'key'
                    val paramType = type.memberProperties.find{ it.name == key || it.findAnnotation<YamlArg>()?.name == key }
                        ?: throw IllegalArgumentException("Parameter '$key' was not found in the class '${type.simpleName}'.")

                    // Dada a classe do parâmetro, alteramos a classe do 'value'
                    val value =
                        when(paramType.returnType.classifier as KClass<*>){
                            Int::class -> strValue.toInt()
                            Double::class -> strValue.toDouble()
                            Float::class -> strValue.toFloat()
                            Long::class -> strValue.toDouble()
                            Char::class -> strValue.toCharArray().first()
                            else -> strValue
                        }

                    // Adicionamos o novo parâmetro ao mapa 'parameters'
                    parameters[key] = value
                    idx++
                }
            }
        }

        return newInstance(parameters)
    }

    final override fun parseList(yaml: Reader): List<T> {

        val returnList = mutableListOf<T>()

        val lines = yaml.readLines()
        var idx = 0

        var count = 0
        var countSpaces = true

        while (idx < lines.size) {

            if(lines[idx].trim().endsWith("-")){

                if(countSpaces){
                    for (char in lines[idx]) if (char == ' ') count++ else break
                    countSpaces = false
                }

                val currentObject = mutableListOf<String>()

                if(lines[0].split(": ").size == 2) {

                    val objTypeName = returnList.first().toString().split(":")[0].trimStart()

                    // Os dois espaços são necessários, de outra forma seria necessário ajeitar a variável 'count'
                    currentObject.add("  $objTypeName: $objTypeName")
                }

                idx++

                while(idx < lines.size && lines[idx].isNotBlank() && !lines[idx].drop(count).startsWith("-")){

                    currentObject.add(lines[idx])
                    idx++
                }

                returnList.add(parseObject(currentObject.joinToString("\n").reader()))
            }
            else if(lines[idx].isNotBlank()){

                // Tipos Primitivos da forma '- Tipo Primitivo' devem ser tratados logo em 'parseList'
                val value = lines[idx].dropWhile { it == ' ' || it == '-' }

                val valueType =
                    when (type) {
                        Char::class -> value.toCharArray().first()
                        Int::class -> value.toInt()
                        Long::class -> value.toLong()
                        Double::class -> value.toDouble()
                        else -> value
                    } as T

                idx++
                returnList.add(valueType)

            }
            else { idx++ }

        }

        return returnList
    }

}
import java.security.spec.InvalidParameterSpecException
import kotlin.reflect.KClass
import kotlin.reflect.KParameter
import kotlin.reflect.full.*
import kotlin.reflect.jvm.isAccessible
import kotlin.reflect.jvm.javaConstructor
import kotlin.reflect.jvm.reflect

/**
 * A YamlParser that uses reflection to parse objects.
 */
class YamlParserReflect<T : Any>(type: KClass<T>) : AbstractYamlParser<T>(type) {
    companion object {
        /**
         *Internal cache of YamlParserReflect instances.
         */
        private val yamlParsers: MutableMap<KClass<*>, YamlParserReflect<*>> = mutableMapOf()
        /**
         * Creates a YamlParser for the given type using reflection if it does not already exist.
         * Keep it in an internal cache of YamlParserReflect instances.
         */
        fun <T : Any> yamlParser(type: KClass<T>): AbstractYamlParser<T> {
            return yamlParsers.getOrPut(type) { YamlParserReflect(type) } as YamlParserReflect<T>
        }
    }
    /**
     * Used to get a parser for other Type using the same parsing approach.
     */
    override fun <T : Any> yamlParser(type: KClass<T>) = YamlParserReflect.yamlParser(type)
    /**
     * Creates a new instance of T through the first constructor
     * that has all the mandatory parameters in the map and optional parameters for the rest.
     */
    override fun newInstance(args: Map<String, Any>): T {
        val params: MutableMap<KParameter, Any?> = mutableMapOf()

        // Get the constructor of the class
        val constructor = type.primaryConstructor
            ?: throw IllegalArgumentException("Class ${type.simpleName} does not have a primary constructor.")

        for (parameter in constructor.parameters) {
            // Check if the parameter has a custom converter specified
            val convertAnnotation = parameter.findAnnotation<YamlConvert>()
            val argValue = args[parameter.name]

            val value = when {
                // Use custom converter if specified
                convertAnnotation != null && argValue is String -> {
                    val converterInstance = convertAnnotation.converter.createInstance()

                    // Ensure the converter has the required function
                    val convertFunction = converterInstance::class.functions.find {
                        it.name == "convertYamlToObject" && it.parameters.size == 2
                    } ?: throw InvalidParameterSpecException("Custom converter must have a function 'convertYamlToObject(yaml: String)'")

                    convertFunction.call(converterInstance, argValue)
                }
                else -> argValue
            }

            if (value == null && !parameter.isOptional) {
                throw IllegalArgumentException("The parameter ${parameter.name} was not assigned a value.")
            }

            // Special handling for 'grades' property
            if (parameter.name == "grades" && value == null) {
                params[parameter] = emptyList<T>() // or any default value you want
            } else {
                params[parameter] = value
            }
        }

        return constructor.callBy(params)
    }
}

Solution

  • Solved by accessing the parameters through the constructor

    val paramType = type.constructors.first().parameters.find{ it.name == key || it.findAnnotation<YamlArg>()?.name == key }
    

    then attributing the paramType.name property to a new key

    var keyA = paramType.name
    

    and finally by passing it in case it exists

    if(keyA != null){
       parameters[keyA] = value
       keyA == null
    }
    else parameters[key] = value