Skip to content

Commit

Permalink
Initial Support for Simple Map decoding with unnamed toml keys (#246)
Browse files Browse the repository at this point in the history
### 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<String, Int>
}
```

Can be useful for parsing gradle configs.
  • Loading branch information
orchestr7 authored Dec 12, 2023
1 parent 42a8daa commit 57f933b
Show file tree
Hide file tree
Showing 7 changed files with 266 additions and 24 deletions.
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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<String, String>
)

@Serializable
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<TomlNode>?, 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()
Expand All @@ -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)
Expand All @@ -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}]"
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <T> decodeSerializableElement(
descriptor: SerialDescriptor,
index: Int,
deserializer: DeserializationStrategy<T>,
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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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<String, Long>,
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<TomlDecodingException> {
Toml.decodeFromString<TestDataMap>(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<TestDataMap>(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<TestDataMap>(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<TestDataMap>(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<TestDataMap>(data)
)
}

@Test
fun testMapDecoderNegativeCases() {
var data = """
a = 1
b = 1
c = 1
text = "Test"
number = 15
""".trimIndent()

assertFailsWith<MissingRequiredPropertyException> {
Toml.decodeFromString<TestDataMap>(data)
}

data = """
[map]
[map.a]
b = 1
[map.b]
c = 1
text = "Test"
number = 15
""".trimIndent()

assertFailsWith<UnsupportedDecoderException> {
Toml.decodeFromString<TestDataMap>(data)
}

data = """
text = "Test"
number = 15
""".trimIndent()

assertFailsWith<MissingRequiredPropertyException> {
Toml.decodeFromString<TestDataMap>(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
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>
)

@Serializable
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand Down

0 comments on commit 57f933b

Please sign in to comment.