From 57f933bd7ea6871477f45c828f7512382e74f100 Mon Sep 17 00:00:00 2001 From: Andrey Kuleshov Date: Wed, 13 Dec 2023 01:02:39 +0300 Subject: [PATCH] Initial Support for Simple Map decoding with unnamed toml keys (#246) ### What's done: - Phaze 1: only primitive tables (without nested multiple tables) with no explicit type checking; - The idea is to decode unnamed arguments. For example: ``` [map] a = 1 b = 2 ``` or ``` map = {a=1, b=2} ``` Should be decoded (without knowing and naming explicitly toml keys) to: ``` data class Test { val map: Map } ``` Can be useful for parsing gradle configs. --- README.md | 7 +- .../ktoml/decoders/TomlArrayDecoder.kt | 11 +- .../ktoml/decoders/TomlMainDecoder.kt | 40 +++-- .../ktoml/decoders/TomlMapDecoder.kt | 71 +++++++++ .../ktoml/exceptions/TomlDecodingException.kt | 1 + .../ktoml/decoders/PlainMapDecoderTest.kt | 150 ++++++++++++++++++ .../ktoml/decoders/ReadMeExampleTest.kt | 10 +- 7 files changed, 266 insertions(+), 24 deletions(-) create mode 100644 ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlMapDecoder.kt create mode 100644 ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/PlainMapDecoderTest.kt diff --git a/README.md b/README.md index 78b6e47..15de5ab 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,10 @@ gradle-libs-like-property = { id = "org.jetbrains.kotlin.jvm", version.ref = "ko property1 = 100 property2 = 6 +[myMap] + a = "b" + c = "d" + [table2] someNumber = 5 [table2."akuleshov7.com"] @@ -312,7 +316,8 @@ data class MyClass( val table1: Table1, val table2: Table2, @SerialName("gradle-libs-like-property") - val kotlinJvm: GradlePlugin + val kotlinJvm: GradlePlugin, + val myMap: Map ) @Serializable diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlArrayDecoder.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlArrayDecoder.kt index e5cb682..e6ca7ec 100644 --- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlArrayDecoder.kt +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlArrayDecoder.kt @@ -1,6 +1,5 @@ package com.akuleshov7.ktoml.decoders -import com.akuleshov7.ktoml.TomlConfig import com.akuleshov7.ktoml.TomlInputConfig import com.akuleshov7.ktoml.tree.nodes.TomlKeyValue import com.akuleshov7.ktoml.tree.nodes.TomlKeyValueArray @@ -30,14 +29,6 @@ public class TomlArrayDecoder( private lateinit var currentElementDecoder: TomlPrimitiveDecoder private lateinit var currentPrimitiveElementOfArray: TomlValue - @Deprecated( - message = "TomlConfig is deprecated; use TomlInputConfig instead. Will be removed in next releases." - ) - public constructor( - rootNode: TomlKeyValueArray, - config: TomlConfig - ) : this(rootNode, config.input) - private fun haveStartedReadingElements() = nextElementIndex > 0 override fun decodeCollectionSize(descriptor: SerialDescriptor): Int = list.size @@ -90,6 +81,6 @@ public class TomlArrayDecoder( super.decodeSerializableValue(deserializer) } - // this should be applied to [currentPrimitiveElementOfArray] and not to the [rootNode], because + // this should be applied to [currentPrimitiveElementOfArray] and not to the [rootNode] override fun decodeNotNullMark(): Boolean = currentPrimitiveElementOfArray !is TomlNull } diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlMainDecoder.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlMainDecoder.kt index 80ea1fb..86c7322 100644 --- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlMainDecoder.kt +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlMainDecoder.kt @@ -7,9 +7,11 @@ import com.akuleshov7.ktoml.TomlInputConfig import com.akuleshov7.ktoml.exceptions.* import com.akuleshov7.ktoml.tree.nodes.* import com.akuleshov7.ktoml.tree.nodes.pairs.values.TomlNull + import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.StructureKind import kotlinx.serialization.descriptors.elementNames import kotlinx.serialization.encoding.CompositeDecoder import kotlinx.serialization.encoding.Decoder @@ -174,9 +176,16 @@ public class TomlMainDecoder( /** * Actually this method is not needed as serialization lib should do everything for us, but let's - * fail-fast in the very beginning if the structure is inconsistent and required properties are missing + * fail-fast in the very beginning if the structure is inconsistent and required properties are missing. + * Also we will throw much more clear ktoml-like exception MissingRequiredPropertyException */ private fun checkMissingRequiredProperties(children: MutableList?, descriptor: SerialDescriptor) { + // the only case when we are not able to check required properties is when our descriptor type is a Map with unnamed properties: + // in this case we will just ignore this check and will put all values that we have in the table to the map + if (descriptor.kind == StructureKind.MAP) { + return + } + val propertyNames = children?.map { it.name } ?: emptyList() @@ -200,20 +209,20 @@ public class TomlMainDecoder( * A hack that comes from a compiler plugin to process Inline (value) classes */ override fun decodeInline(inlineDescriptor: SerialDescriptor): Decoder = - iterateOverStructure(inlineDescriptor, true) + iterateOverTomlStructure(inlineDescriptor, true) /** * this method does all the iteration logic for processing code structures and collections * treat it as an !entry point! and the orchestrator of the decoding */ override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder = - iterateOverStructure(descriptor, false) + iterateOverTomlStructure(descriptor, false) /** * Entry Point into the logic, core logic of the structure traversal and linking the data from TOML AST * to the descriptor and vica-versa. Basically this logic is used to iterate through data structures and do processing. */ - private fun iterateOverStructure(descriptor: SerialDescriptor, inlineFunc: Boolean): TomlAbstractDecoder = + private fun iterateOverTomlStructure(descriptor: SerialDescriptor, inlineFunc: Boolean): TomlAbstractDecoder = if (rootNode is TomlFile) { checkMissingRequiredProperties(rootNode.children, descriptor) val firstFileChild = getFirstChild(rootNode) @@ -233,14 +242,23 @@ public class TomlMainDecoder( when (nextProcessingNode) { is TomlKeyValueArray -> TomlArrayDecoder(nextProcessingNode, config) is TomlKeyValuePrimitive, is TomlStubEmptyNode -> TomlMainDecoder(nextProcessingNode, config) - is TomlTable -> { - val firstTableChild = nextProcessingNode.getFirstChild() ?: throw InternalDecodingException( - "Decoding process has failed due to invalid structure of parsed AST tree: missing children" + - " in a table <${nextProcessingNode.fullTableKey}>" - ) - checkMissingRequiredProperties(firstTableChild.getNeighbourNodes(), descriptor) - TomlMainDecoder(firstTableChild, config) + is TomlTable -> when (descriptor.kind) { + // This logic is a special case when user would like to parse key-values from a table to a map. + // It can be useful, when the user does not know key names of TOML key-value pairs, for example: + // if parsing + StructureKind.MAP -> TomlMapDecoder(nextProcessingNode) + + else -> { + val firstTableChild = nextProcessingNode.getFirstChild() ?: throw InternalDecodingException( + "Decoding process has failed due to invalid structure of parsed AST tree: missing children" + + " in a table <${nextProcessingNode.fullTableKey}>" + ) + + checkMissingRequiredProperties(firstTableChild.getNeighbourNodes(), descriptor) + TomlMainDecoder(firstTableChild, config) + } } + else -> throw InternalDecodingException( "Incorrect decoding state in the beginStructure()" + " with $nextProcessingNode ($nextProcessingNode)[${nextProcessingNode.name}]" diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlMapDecoder.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlMapDecoder.kt new file mode 100644 index 0000000..5d1f225 --- /dev/null +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/decoders/TomlMapDecoder.kt @@ -0,0 +1,71 @@ +package com.akuleshov7.ktoml.decoders + +import com.akuleshov7.ktoml.exceptions.UnsupportedDecoderException +import com.akuleshov7.ktoml.tree.nodes.TomlKeyValue +import com.akuleshov7.ktoml.tree.nodes.TomlTable +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule + +/** + * Sometimes, when you do not know the names of the TOML keys and cannot create a proper class with field names for parsing, + * it can be useful to read and parse TOML tables to a map. This is exactly what this TomlMapDecoder is used for. + * + * @property rootNode toml table that we are trying to decode + * @property decodingElementIndex for iterating over the TOML table we are currently reading + * @property kotlinxIndex for iteration inside the kotlinX loop: [decodeElementIndex -> decodeSerializableElement] + */ +@ExperimentalSerializationApi +public class TomlMapDecoder( + private val rootNode: TomlTable, + private var decodingElementIndex: Int = 0, + private var kotlinxIndex: Int = 0, +) : TomlAbstractDecoder() { + override val serializersModule: SerializersModule = EmptySerializersModule() + + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + // we will iterate in the following way: + // for [map] + // a = 1 + // b = 2 + // kotlinxIndex will be 0, 1, 2 ,3 + // and decodingElementIndex will be 0, 1 (as there are only two elements in the table: 'a' and 'b') + decodingElementIndex = kotlinxIndex / 2 + + if (decodingElementIndex == rootNode.children.size) { + return CompositeDecoder.DECODE_DONE + } + + return kotlinxIndex++ + } + + override fun decodeSerializableElement( + descriptor: SerialDescriptor, + index: Int, + deserializer: DeserializationStrategy, + previousValue: T? + ): T { + val returnValue = when (val processedNode = rootNode.children[decodingElementIndex]) { + // simple decoding for key-value type + is TomlKeyValue -> processedNode + else -> throw UnsupportedDecoderException( + """ Attempting to decode <$rootNode>; however, custom Map decoders do not currently support nested structures. + Decoding is limited to plain structures only: + [map] + a = 1 + b = 2 + c = "3" + """ + ) + } + + return ((if (index % 2 == 0) returnValue.key.toString() else returnValue.value.content)) as T + } + + override fun decodeKeyValue(): TomlKeyValue { + TODO("No need to implement decodeKeyValue for TomlMapDecoder as it is not needed for such primitive decoders") + } +} diff --git a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/exceptions/TomlDecodingException.kt b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/exceptions/TomlDecodingException.kt index 20c4ad8..07fb4a0 100644 --- a/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/exceptions/TomlDecodingException.kt +++ b/ktoml-core/src/commonMain/kotlin/com/akuleshov7/ktoml/exceptions/TomlDecodingException.kt @@ -48,3 +48,4 @@ internal class NullValueException(propertyName: String, lineNo: Int) : TomlDecod internal class IllegalTypeException(message: String, lineNo: Int) : TomlDecodingException("Line $lineNo: $message") internal class MissingRequiredPropertyException(message: String) : TomlDecodingException(message) +internal class UnsupportedDecoderException(message: String) : TomlDecodingException(message) diff --git a/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/PlainMapDecoderTest.kt b/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/PlainMapDecoderTest.kt new file mode 100644 index 0000000..afadd32 --- /dev/null +++ b/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/PlainMapDecoderTest.kt @@ -0,0 +1,150 @@ +package com.akuleshov7.ktoml.decoders + +import com.akuleshov7.ktoml.Toml +import com.akuleshov7.ktoml.exceptions.MissingRequiredPropertyException +import com.akuleshov7.ktoml.exceptions.TomlDecodingException +import com.akuleshov7.ktoml.exceptions.UnsupportedDecoderException + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.Serializable +import kotlin.test.* + +class PlainMapDecoderTest { + @Serializable + private data class TestDataMap( + val text: String = "Test", + val map: Map, + val number: Int = 31, + ) + + @Test + @Ignore + fun typeErrorsInDecoding() { + val data = """ + text = "Test" + number = 15 + [map] + a = "fff" + b = 2 + c = 3 + number = 31 + e = 4 + """.trimIndent() + + assertFailsWith { + Toml.decodeFromString(data) + } + } + + @Test + fun testMapDecoderPositiveCase() { + var data = """ + text = "Test" + number = 15 + [map] + a = 1 + b = 2 + c = 3 + number = 31 + e = 4 + """.trimIndent() + + assertEquals( + TestDataMap("Test", mapOf("a" to 1, "b" to 2, "c" to 3, "number" to 31, "e" to 4), 15), + Toml.decodeFromString(data) + ) + + data = """ + text = "Test" + number = 15 + [map] + a = 1 + b = 2 + c = 3 + number = 31 + # e = 4 + """.trimIndent() + + assertEquals( + TestDataMap("Test", mapOf("a" to 1, "b" to 2, "c" to 3, "number" to 31), 15), + Toml.decodeFromString(data) + ) + + data = """ + [map] + a = 1 + b = 2 + c = 3 + number = 15 + # e = 4 + """.trimIndent() + + assertEquals( + TestDataMap("Test", mapOf("a" to 1, "b" to 2, "c" to 3, "number" to 15), 31), + Toml.decodeFromString(data) + ) + + + data = """ + map = { a = 1, b = 2, c = 3, number = 15 } + text = "Test" + number = 15 + """.trimIndent() + + assertEquals( + TestDataMap("Test", mapOf("a" to 1, "b" to 2, "c" to 3, "number" to 15), 15), + Toml.decodeFromString(data) + ) + } + + @Test + fun testMapDecoderNegativeCases() { + var data = """ + a = 1 + b = 1 + c = 1 + text = "Test" + number = 15 + """.trimIndent() + + assertFailsWith { + Toml.decodeFromString(data) + } + + data = """ + [map] + [map.a] + b = 1 + [map.b] + c = 1 + text = "Test" + number = 15 + """.trimIndent() + + assertFailsWith { + Toml.decodeFromString(data) + } + + data = """ + text = "Test" + number = 15 + """.trimIndent() + + assertFailsWith { + Toml.decodeFromString(data) + } + } + + @Test + fun testSimpleMapDecoder() { + val data = TestDataMap(text = "text value", number = 7321, map = mapOf("a" to 3, "c" to 4)) + val encoded = Toml.encodeToString(data) + val decoded: TestDataMap = Toml.decodeFromString(encoded) + + assertEquals( + data, + decoded + ) + } +} diff --git a/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/ReadMeExampleTest.kt b/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/ReadMeExampleTest.kt index e67081e..df9202a 100644 --- a/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/ReadMeExampleTest.kt +++ b/ktoml-core/src/commonTest/kotlin/com/akuleshov7/ktoml/decoders/ReadMeExampleTest.kt @@ -15,7 +15,8 @@ class ReadMeExampleTest { val table1: Table1, val table2: Table2, @SerialName("gradle-libs-like-property") - val kotlinJvm: GradlePlugin + val kotlinJvm: GradlePlugin, + val myMap: Map ) @Serializable @@ -68,6 +69,10 @@ class ReadMeExampleTest { # so for 'property1' null value is ok. Use: property1 = null property1 = 100 property2 = 6 + + [myMap] + a = "b" + c = "d" [table2] someNumber = 5 @@ -107,7 +112,8 @@ class ReadMeExampleTest { charFromInteger = '{' ), - kotlinJvm = GradlePlugin("org.jetbrains.kotlin.jvm", Version("kotlin")) + kotlinJvm = GradlePlugin("org.jetbrains.kotlin.jvm", Version("kotlin")), + myMap = mapOf("a" to "b", "c" to "d") ), decoded )